From 17f94dc202857c8a984d90fcead92ca0e5ac9fe5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Se=CC=81bastien=20Loix?= Date: Mon, 31 May 2021 12:59:14 +0100 Subject: [PATCH 01/41] Add field preview header and empty prompt --- .../components/field_editor/field_editor.tsx | 2 +- .../field_editor_flyout_content.tsx | 4 +- .../components/preview/field_preview.tsx | 80 +++---------------- .../preview/field_preview_context.tsx | 25 ++++-- .../preview/field_preview_empty_prompt.tsx | 43 ++++++++++ .../preview/field_preview_header.tsx | 44 ++++++++++ 6 files changed, 124 insertions(+), 74 deletions(-) create mode 100644 src/plugins/index_pattern_field_editor/public/components/preview/field_preview_empty_prompt.tsx create mode 100644 src/plugins/index_pattern_field_editor/public/components/preview/field_preview_header.tsx diff --git a/src/plugins/index_pattern_field_editor/public/components/field_editor/field_editor.tsx b/src/plugins/index_pattern_field_editor/public/components/field_editor/field_editor.tsx index 39b9b7ee5162e..4c66d6ea13702 100644 --- a/src/plugins/index_pattern_field_editor/public/components/field_editor/field_editor.tsx +++ b/src/plugins/index_pattern_field_editor/public/components/field_editor/field_editor.tsx @@ -158,7 +158,7 @@ const FieldEditorComponent = ({ field, onChange, syntaxError }: Props) => { fields, error, updateParams: updatePreviewParams, - setIsPanelVisible, + panel: { setIsVisible: setIsPanelVisible }, } = useFieldPreviewContext(); const { form } = useForm({ defaultValue: field, diff --git a/src/plugins/index_pattern_field_editor/public/components/field_editor_flyout_content.tsx b/src/plugins/index_pattern_field_editor/public/components/field_editor_flyout_content.tsx index 778c78f7921c7..67bba9c9bfc2b 100644 --- a/src/plugins/index_pattern_field_editor/public/components/field_editor_flyout_content.tsx +++ b/src/plugins/index_pattern_field_editor/public/components/field_editor_flyout_content.tsx @@ -105,7 +105,9 @@ const FieldEditorFlyoutContentComponent = ({ const isEditingExistingField = !!field; const i18nTexts = geti18nTexts(field); const { indexPattern } = useFieldEditorContext(); - const { isPanelVisible } = useFieldPreviewContext(); + const { + panel: { isVisible: isPanelVisible }, + } = useFieldPreviewContext(); const [formState, setFormState] = useState({ isSubmitted: false, diff --git a/src/plugins/index_pattern_field_editor/public/components/preview/field_preview.tsx b/src/plugins/index_pattern_field_editor/public/components/preview/field_preview.tsx index a9b402e7edd36..6c38f0f5e5b79 100644 --- a/src/plugins/index_pattern_field_editor/public/components/preview/field_preview.tsx +++ b/src/plugins/index_pattern_field_editor/public/components/preview/field_preview.tsx @@ -6,77 +6,23 @@ * Side Public License, v 1. */ import React from 'react'; -import { EuiTitle, EuiSpacer } from '@elastic/eui'; +import { EuiSpacer } from '@elastic/eui'; + +import { FieldPreviewHeader } from './field_preview_header'; +import { FieldPreviewEmptyPrompt } from './field_preview_empty_prompt'; export const FieldPreview = () => { + const isHeaderVisible = false; + return ( <> - -

Preview ....

-
- - - -

- Lorem ipsum dolor sit amet consectetur adipisicing elit. Nam minus eligendi perferendis - alias inventore voluptatum quidem nulla ducimus rem tenetur numquam esse ipsa, repudiandae - eveniet distinctio? Modi doloribus assumenda eum! -

-

- Lorem ipsum dolor sit amet consectetur adipisicing elit. Nam minus eligendi perferendis - alias inventore voluptatum quidem nulla ducimus rem tenetur numquam esse ipsa, repudiandae - eveniet distinctio? Modi doloribus assumenda eum! -

-

- Lorem ipsum dolor sit amet consectetur adipisicing elit. Nam minus eligendi perferendis - alias inventore voluptatum quidem nulla ducimus rem tenetur numquam esse ipsa, repudiandae - eveniet distinctio? Modi doloribus assumenda eum! -

-

- Lorem ipsum dolor sit amet consectetur adipisicing elit. Nam minus eligendi perferendis - alias inventore voluptatum quidem nulla ducimus rem tenetur numquam esse ipsa, repudiandae - eveniet distinctio? Modi doloribus assumenda eum! -

-

- Lorem ipsum dolor sit amet consectetur adipisicing elit. Nam minus eligendi perferendis - alias inventore voluptatum quidem nulla ducimus rem tenetur numquam esse ipsa, repudiandae - eveniet distinctio? Modi doloribus assumenda eum! -

-

- Lorem ipsum dolor sit amet consectetur adipisicing elit. Nam minus eligendi perferendis - alias inventore voluptatum quidem nulla ducimus rem tenetur numquam esse ipsa, repudiandae - eveniet distinctio? Modi doloribus assumenda eum! -

-

- Lorem ipsum dolor sit amet consectetur adipisicing elit. Nam minus eligendi perferendis - alias inventore voluptatum quidem nulla ducimus rem tenetur numquam esse ipsa, repudiandae - eveniet distinctio? Modi doloribus assumenda eum! -

-

- Lorem ipsum dolor sit amet consectetur adipisicing elit. Nam minus eligendi perferendis - alias inventore voluptatum quidem nulla ducimus rem tenetur numquam esse ipsa, repudiandae - eveniet distinctio? Modi doloribus assumenda eum! -

-

- Lorem ipsum dolor sit amet consectetur adipisicing elit. Nam minus eligendi perferendis - alias inventore voluptatum quidem nulla ducimus rem tenetur numquam esse ipsa, repudiandae - eveniet distinctio? Modi doloribus assumenda eum! -

-

- Lorem ipsum dolor sit amet consectetur adipisicing elit. Nam minus eligendi perferendis - alias inventore voluptatum quidem nulla ducimus rem tenetur numquam esse ipsa, repudiandae - eveniet distinctio? Modi doloribus assumenda eum! -

-

- Lorem ipsum dolor sit amet consectetur adipisicing elit. Nam minus eligendi perferendis - alias inventore voluptatum quidem nulla ducimus rem tenetur numquam esse ipsa, repudiandae - eveniet distinctio? Modi doloribus assumenda eum! -

-

- Lorem ipsum dolor sit amet consectetur adipisicing elit. Nam minus eligendi perferendis - alias inventore voluptatum quidem nulla ducimus rem tenetur numquam esse ipsa, repudiandae - eveniet distinctio? Modi doloribus assumenda eum! -

+ {isHeaderVisible && ( + <> + + + + )} + ); }; diff --git a/src/plugins/index_pattern_field_editor/public/components/preview/field_preview_context.tsx b/src/plugins/index_pattern_field_editor/public/components/preview/field_preview_context.tsx index 9c3ea1054a8c0..58d6da819aaa0 100644 --- a/src/plugins/index_pattern_field_editor/public/components/preview/field_preview_context.tsx +++ b/src/plugins/index_pattern_field_editor/public/components/preview/field_preview_context.tsx @@ -23,13 +23,21 @@ import { parseEsError } from '../../lib/runtime_field_validation'; import { RuntimeType, RuntimeField } from '../../shared_imports'; import { useFieldEditorContext } from '../field_editor_context'; +type From = 'cluster' | 'custom'; + interface Context { fields: Array<{ key: string; value: unknown }>; error: Record | null; updateParams: (updated: Partial) => void; currentDocument?: Record; - isPanelVisible: boolean; - setIsPanelVisible: (isVisible: boolean) => void; + panel: { + isVisible: boolean; + setIsVisible: (isVisible: boolean) => void; + }; + from: { + value: From; + set: (value: From) => void; + }; navigation: { isFirstDoc: boolean; isLastDoc: boolean; @@ -69,6 +77,7 @@ export const FieldPreviewProvider: FunctionComponent = ({ children }) => { const [documents, setDocuments] = useState>>([]); const [navDocsIndex, setNavDocsIndex] = useState(0); const [isPanelVisible, setIsPanelVisible] = useState(false); + const [from, setFrom] = useState('cluster'); const areAllParamsDefined = Object.values(params).filter(Boolean).length === Object.keys(defaultParams).length; @@ -168,8 +177,14 @@ export const FieldPreviewProvider: FunctionComponent = ({ children }) => { next: goToNextDoc, prev: goToPrevDoc, }, - isPanelVisible, - setIsPanelVisible, + panel: { + isVisible: isPanelVisible, + setIsVisible: setIsPanelVisible, + }, + from: { + value: from, + set: setFrom, + }, }), [ previewResponse, @@ -180,7 +195,7 @@ export const FieldPreviewProvider: FunctionComponent = ({ children }) => { goToNextDoc, goToPrevDoc, isPanelVisible, - setIsPanelVisible, + from, ] ); diff --git a/src/plugins/index_pattern_field_editor/public/components/preview/field_preview_empty_prompt.tsx b/src/plugins/index_pattern_field_editor/public/components/preview/field_preview_empty_prompt.tsx new file mode 100644 index 0000000000000..21dbdf3df7260 --- /dev/null +++ b/src/plugins/index_pattern_field_editor/public/components/preview/field_preview_empty_prompt.tsx @@ -0,0 +1,43 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React from 'react'; +import { i18n } from '@kbn/i18n'; +import { EuiEmptyPrompt, EuiText, EuiTextColor, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; + +export const FieldPreviewEmptyPrompt = () => { + return ( + + + + {i18n.translate('indexPatternFieldEditor.fieldPreview.emptyPromptTitle', { + defaultMessage: 'Preview', + })} + + } + titleSize="s" + body={ + + +

+ {i18n.translate('indexPatternFieldEditor.fieldPreview.emptyPromptDescription', { + defaultMessage: + 'Configure field value or format to see a preview of our new fields will appear.', + })} +

+
+
+ } + /> +
+
+ ); +}; diff --git a/src/plugins/index_pattern_field_editor/public/components/preview/field_preview_header.tsx b/src/plugins/index_pattern_field_editor/public/components/preview/field_preview_header.tsx new file mode 100644 index 0000000000000..716e911951f31 --- /dev/null +++ b/src/plugins/index_pattern_field_editor/public/components/preview/field_preview_header.tsx @@ -0,0 +1,44 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React from 'react'; +import { EuiTitle, EuiText, EuiTextColor } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; + +import { useFieldEditorContext } from '../field_editor_context'; +import { useFieldPreviewContext } from './field_preview_context'; + +const i18nTexts = { + customData: i18n.translate('indexPatternFieldEditor.fieldPreview.subTitle.customData', { + defaultMessage: 'Custom data', + }), +}; + +export const FieldPreviewHeader = () => { + const { indexPattern } = useFieldEditorContext(); + const { from } = useFieldPreviewContext(); + return ( + <> + +

+ {i18n.translate('indexPatternFieldEditor.fieldPreview.title', { + defaultMessage: 'Preview', + })} +

+
+ + + {i18n.translate('indexPatternFieldEditor.fieldPreview.subTitle', { + defaultMessage: 'From: {from}', + values: { from: from.value === 'cluster' ? indexPattern.title : i18nTexts.customData }, + })} + + + + ); +}; From e96e33fae4be7fefc6b96781c753d8ba3bc842c9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Se=CC=81bastien=20Loix?= Date: Mon, 31 May 2021 17:59:00 +0100 Subject: [PATCH 02/41] Add preview document navigation --- .../components/preview/field_preview.tsx | 11 +- .../preview/field_preview_context.tsx | 93 ++++++++++++-- .../preview/preview_documents_nav.tsx | 118 ++++++++++++++++++ 3 files changed, 207 insertions(+), 15 deletions(-) create mode 100644 src/plugins/index_pattern_field_editor/public/components/preview/preview_documents_nav.tsx diff --git a/src/plugins/index_pattern_field_editor/public/components/preview/field_preview.tsx b/src/plugins/index_pattern_field_editor/public/components/preview/field_preview.tsx index 6c38f0f5e5b79..1d6abb72b921f 100644 --- a/src/plugins/index_pattern_field_editor/public/components/preview/field_preview.tsx +++ b/src/plugins/index_pattern_field_editor/public/components/preview/field_preview.tsx @@ -8,21 +8,26 @@ import React from 'react'; import { EuiSpacer } from '@elastic/eui'; +import { useFieldPreviewContext } from './field_preview_context'; import { FieldPreviewHeader } from './field_preview_header'; import { FieldPreviewEmptyPrompt } from './field_preview_empty_prompt'; +import { PreviewDocumentsNav } from './preview_documents_nav'; export const FieldPreview = () => { - const isHeaderVisible = false; + const { fields, error } = useFieldPreviewContext(); + const isEmptyPromptVisible = fields.length === 0 && error === null; return ( <> - {isHeaderVisible && ( + {isEmptyPromptVisible ? ( + + ) : ( <> + )} - ); }; diff --git a/src/plugins/index_pattern_field_editor/public/components/preview/field_preview_context.tsx b/src/plugins/index_pattern_field_editor/public/components/preview/field_preview_context.tsx index 58d6da819aaa0..869f907217600 100644 --- a/src/plugins/index_pattern_field_editor/public/components/preview/field_preview_context.tsx +++ b/src/plugins/index_pattern_field_editor/public/components/preview/field_preview_context.tsx @@ -25,11 +25,18 @@ import { useFieldEditorContext } from '../field_editor_context'; type From = 'cluster' | 'custom'; +type EsDocument = Record; + interface Context { fields: Array<{ key: string; value: unknown }>; error: Record | null; updateParams: (updated: Partial) => void; - currentDocument?: Record; + currentDocument: { + value?: EsDocument; + loadSingle: (id: string) => Promise; + loadFromCluster: () => Promise; + isCustomID: boolean; + }; panel: { isVisible: boolean; setIsVisible: (isVisible: boolean) => void; @@ -51,7 +58,7 @@ interface Params { index: string | null; type: RuntimeType | null; script: Required['script'] | null; - document: Record | null; + document: EsDocument | null; } const fieldPreviewContext = createContext(undefined); @@ -69,15 +76,26 @@ export const FieldPreviewProvider: FunctionComponent = ({ children }) => { }, } = useFieldEditorContext(); + /** Response from the Painless _execute API */ const [previewResponse, setPreviewResponse] = useState<{ fields: Context['fields']; error: Context['error']; }>({ fields: [], error: null }); + /** The parameters required for the Painless _execute API */ const [params, setParams] = useState(defaultParams); - const [documents, setDocuments] = useState>>([]); + /** The sample documents fetched from the cluster */ + const [documents, setDocuments] = useState([]); + /** The current Array index of the document we are previewing (when previewing from the cluster) */ const [navDocsIndex, setNavDocsIndex] = useState(0); + /** Flag to show/hide the preview panel */ const [isPanelVisible, setIsPanelVisible] = useState(false); + /** Define if we provide the document to preview from the cluster or from a custom JSON */ const [from, setFrom] = useState('cluster'); + /** + * Flag to indicate if the current document comes from a custom ID provided by the user + * If it does we won't display the "Previous" and "Next" button to navigate between documents + */ + const [isCustomID, setIsCustomID] = useState(false); const areAllParamsDefined = Object.values(params).filter(Boolean).length === Object.keys(defaultParams).length; @@ -107,13 +125,48 @@ export const FieldPreviewProvider: FunctionComponent = ({ children }) => { }) .toPromise(); + setIsCustomID(false); + if (response) { + setNavDocsIndex(0); setDocuments(response.rawResponse.hits.hits); } }, [indexPattern, search] ); + const loadDocument = useCallback( + async (id: string) => { + setIsCustomID(true); + + const response = await search + .search({ + params: { + index: indexPattern.title, + body: { + size: 1, + query: { + ids: { + values: [id], + }, + }, + }, + }, + }) + .toPromise(); + + if (response) { + if (response.rawResponse.hits.total > 0) { + setNavDocsIndex(0); + setDocuments(response.rawResponse.hits.hits); + } else { + // TODO: Not found + } + } + }, + [indexPattern, search] + ); + const updatePreview = useCallback(async () => { if (fieldTypeToProcess !== 'runtime') { return; @@ -153,24 +206,29 @@ export const FieldPreviewProvider: FunctionComponent = ({ children }) => { const goToNextDoc = useCallback(() => { if (navDocsIndex >= totalDocs - 1) { - return; + setNavDocsIndex(0); } setNavDocsIndex((prev) => prev + 1); }, [navDocsIndex, totalDocs]); const goToPrevDoc = useCallback(() => { if (navDocsIndex === 0) { - return; + setNavDocsIndex(totalDocs - 1); } setNavDocsIndex((prev) => prev - 1); - }, [navDocsIndex]); + }, [navDocsIndex, totalDocs]); const ctx = useMemo( () => ({ fields: previewResponse.fields, error: previewResponse.error, updateParams, - currentDocument, + currentDocument: { + value: currentDocument, + loadSingle: loadDocument, + loadFromCluster: fetchSampleDocuments, + isCustomID, + }, navigation: { isFirstDoc: navDocsIndex === 0, isLastDoc: navDocsIndex >= totalDocs - 1, @@ -190,6 +248,9 @@ export const FieldPreviewProvider: FunctionComponent = ({ children }) => { previewResponse, updateParams, currentDocument, + loadDocument, + fetchSampleDocuments, + isCustomID, navDocsIndex, totalDocs, goToNextDoc, @@ -213,20 +274,28 @@ export const FieldPreviewProvider: FunctionComponent = ({ children }) => { [areAllParamsDefined, updatePreview] ); + /** + * When the component mounts, if we are creating/editing a runtime field + * we fetch sample documents from the cluster to be able to preview the runtime + * field along with other document fields + */ useEffect(() => { if (fieldTypeToProcess === 'runtime') { fetchSampleDocuments(); } }, [fetchSampleDocuments, fieldTypeToProcess]); + /** + * Each time the current document changes we update the parameters + * for the Painless _execute API call. + */ useEffect(() => { - updateParams({ document: currentDocument?._source }); + updateParams({ + document: currentDocument?._source, + index: currentDocument?._index, + }); }, [currentDocument, updateParams]); - useEffect(() => { - updateParams({ index: currentDocIndex }); - }, [currentDocIndex, updateParams]); - return {children}; }; diff --git a/src/plugins/index_pattern_field_editor/public/components/preview/preview_documents_nav.tsx b/src/plugins/index_pattern_field_editor/public/components/preview/preview_documents_nav.tsx new file mode 100644 index 0000000000000..7925eb863daff --- /dev/null +++ b/src/plugins/index_pattern_field_editor/public/components/preview/preview_documents_nav.tsx @@ -0,0 +1,118 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ +import React, { useCallback, useState, useEffect } from 'react'; +import useDebounce from 'react-use/lib/useDebounce'; +import { i18n } from '@kbn/i18n'; +import { + EuiFlexGroup, + EuiFlexItem, + EuiFormRow, + EuiFieldText, + EuiButtonIcon, + EuiButtonEmpty, +} from '@elastic/eui'; + +import { useFieldPreviewContext } from './field_preview_context'; + +export const PreviewDocumentsNav = () => { + const { + currentDocument: { value: currentDocument, loadSingle, loadFromCluster, isCustomID }, + navigation: { prev, next }, + } = useFieldPreviewContext(); + + const [documentId, setDocumentId] = useState(''); + + const errorMessage = null; + const isInvalid = false; + + // We don't display the nav button when the user has entered a custom + // document ID as at that point there is no more reference to what's "next" + const showNavButtons = isCustomID === false; + + const onDocumentIdChange = useCallback(async (e: React.SyntheticEvent) => { + const nextId = e.currentTarget.value; + setDocumentId(nextId); + }, []); + + useEffect(() => { + if (currentDocument) { + setDocumentId(currentDocument._id); + } + }, [currentDocument]); + + useDebounce( + () => { + if (!Boolean(documentId.trim())) { + return; + } + + if (documentId === currentDocument?._id) { + return; + } + + loadSingle(documentId); + }, + 500, + [documentId, currentDocument] + ); + + return ( + + + + + + {isCustomID && ( + + loadFromCluster()}> + Load latest documents from cluster + + + )} + + + {showNavButtons && ( + + + + + + + + + + + )} + + ); +}; From cd80e349faccea114c8feaed7c03d68e6545665b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Se=CC=81bastien=20Loix?= Date: Tue, 1 Jun 2021 15:30:19 +0100 Subject: [PATCH 03/41] Add basic error handling when fetching documents --- .../components/field_editor/field_editor.tsx | 11 +- .../components/preview/field_preview.tsx | 13 ++- .../preview/field_preview_context.tsx | 109 +++++++++++++----- .../preview/field_preview_empty_prompt.tsx | 2 +- .../preview/field_preview_error.tsx | 31 +++++ .../preview/preview_documents_nav.tsx | 64 +++++++--- 6 files changed, 179 insertions(+), 51 deletions(-) create mode 100644 src/plugins/index_pattern_field_editor/public/components/preview/field_preview_error.tsx diff --git a/src/plugins/index_pattern_field_editor/public/components/field_editor/field_editor.tsx b/src/plugins/index_pattern_field_editor/public/components/field_editor/field_editor.tsx index 4c66d6ea13702..3f22424b19f51 100644 --- a/src/plugins/index_pattern_field_editor/public/components/field_editor/field_editor.tsx +++ b/src/plugins/index_pattern_field_editor/public/components/field_editor/field_editor.tsx @@ -157,7 +157,7 @@ const FieldEditorComponent = ({ field, onChange, syntaxError }: Props) => { const { fields, error, - updateParams: updatePreviewParams, + params: { update: updatePreviewParams }, panel: { setIsVisible: setIsPanelVisible }, } = useFieldPreviewContext(); const { form } = useForm({ @@ -205,11 +205,14 @@ const FieldEditorComponent = ({ field, onChange, syntaxError }: Props) => { useEffect(() => { updatePreviewParams({ - name: updatedName, + name: Boolean(updatedName?.trim()) ? updatedName : null, type: updatedType?.[0].value, - script: Boolean(updatedScript?.source.trim()) ? updatedScript : null, + script: + isValueVisible === false || Boolean(updatedScript?.source.trim()) === false + ? null + : updatedScript, }); - }, [updatedName, updatedType, updatedScript, updatePreviewParams]); + }, [updatedName, updatedType, updatedScript, isValueVisible, updatePreviewParams]); useEffect(() => { if (isValueVisible || isFormatVisible) { diff --git a/src/plugins/index_pattern_field_editor/public/components/preview/field_preview.tsx b/src/plugins/index_pattern_field_editor/public/components/preview/field_preview.tsx index 1d6abb72b921f..1322193193cc6 100644 --- a/src/plugins/index_pattern_field_editor/public/components/preview/field_preview.tsx +++ b/src/plugins/index_pattern_field_editor/public/components/preview/field_preview.tsx @@ -12,10 +12,17 @@ import { useFieldPreviewContext } from './field_preview_context'; import { FieldPreviewHeader } from './field_preview_header'; import { FieldPreviewEmptyPrompt } from './field_preview_empty_prompt'; import { PreviewDocumentsNav } from './preview_documents_nav'; +import { FieldPreviewError } from './field_preview_error'; export const FieldPreview = () => { - const { fields, error } = useFieldPreviewContext(); - const isEmptyPromptVisible = fields.length === 0 && error === null; + const { + params: { + value: { name, script, format }, + }, + } = useFieldPreviewContext(); + + // To show the preview we at least need a name to be defined and the script or the format + const isEmptyPromptVisible = name === null || (script !== null && format !== null); return ( <> @@ -26,6 +33,8 @@ export const FieldPreview = () => { + + )} diff --git a/src/plugins/index_pattern_field_editor/public/components/preview/field_preview_context.tsx b/src/plugins/index_pattern_field_editor/public/components/preview/field_preview_context.tsx index 869f907217600..c2528ca2db107 100644 --- a/src/plugins/index_pattern_field_editor/public/components/preview/field_preview_context.tsx +++ b/src/plugins/index_pattern_field_editor/public/components/preview/field_preview_context.tsx @@ -18,7 +18,7 @@ import React, { import useDebounce from 'react-use/lib/useDebounce'; import { i18n } from '@kbn/i18n'; -import type { FieldPreviewContext } from '../../types'; +import type { FieldPreviewContext, FieldFormatConfig } from '../../types'; import { parseEsError } from '../../lib/runtime_field_validation'; import { RuntimeType, RuntimeField } from '../../shared_imports'; import { useFieldEditorContext } from '../field_editor_context'; @@ -27,15 +27,32 @@ type From = 'cluster' | 'custom'; type EsDocument = Record; +interface PreviewError { + code: 'DOC_NOT_FOUND' | 'PAINLESS_SCRIPT_ERROR'; + error: Record; +} + +interface Params { + name: string | null; + index: string | null; + type: RuntimeType | null; + script: Required['script'] | null; + format: FieldFormatConfig | null; + document: EsDocument | null; +} + interface Context { fields: Array<{ key: string; value: unknown }>; - error: Record | null; - updateParams: (updated: Partial) => void; + error: PreviewError | null; + params: { + value: Params; + update: (updated: Partial) => void; + }; currentDocument: { value?: EsDocument; loadSingle: (id: string) => Promise; loadFromCluster: () => Promise; - isCustomID: boolean; + isLoading: boolean; }; panel: { isVisible: boolean; @@ -53,17 +70,16 @@ interface Context { }; } -interface Params { - name: string | null; - index: string | null; - type: RuntimeType | null; - script: Required['script'] | null; - document: EsDocument | null; -} - const fieldPreviewContext = createContext(undefined); -const defaultParams: Params = { name: null, index: null, script: null, document: null, type: null }; +const defaultParams: Params = { + name: null, + index: null, + script: null, + document: null, + type: null, + format: null, +}; export const FieldPreviewProvider: FunctionComponent = ({ children }) => { const { @@ -89,16 +105,15 @@ export const FieldPreviewProvider: FunctionComponent = ({ children }) => { const [navDocsIndex, setNavDocsIndex] = useState(0); /** Flag to show/hide the preview panel */ const [isPanelVisible, setIsPanelVisible] = useState(false); + /** Flag to indicate if we are loading document from cluster */ + const [isFetchingDocument, setIsFetchingDocument] = useState(false); /** Define if we provide the document to preview from the cluster or from a custom JSON */ const [from, setFrom] = useState('cluster'); - /** - * Flag to indicate if the current document comes from a custom ID provided by the user - * If it does we won't display the "Previous" and "Next" button to navigate between documents - */ - const [isCustomID, setIsCustomID] = useState(false); - const areAllParamsDefined = - Object.values(params).filter(Boolean).length === Object.keys(defaultParams).length; + const areAllParamsDefined = Object.entries(params) + // We don't need the "format" information for the _execute API + .filter(([key]) => key !== 'format') + .every(([_, value]) => Boolean(value)); const currentDocument: Record | undefined = useMemo(() => documents[navDocsIndex], [ documents, @@ -108,12 +123,15 @@ export const FieldPreviewProvider: FunctionComponent = ({ children }) => { const currentDocIndex = currentDocument?._index; const totalDocs = documents.length; - const updateParams: Context['updateParams'] = useCallback((updated) => { + const updateParams: Context['params']['update'] = useCallback((updated) => { setParams((prev) => ({ ...prev, ...updated })); }, []); const fetchSampleDocuments = useCallback( async (limit = 50) => { + setPreviewResponse({ fields: [], error: null }); + setIsFetchingDocument(true); + const response = await search .search({ params: { @@ -125,11 +143,13 @@ export const FieldPreviewProvider: FunctionComponent = ({ children }) => { }) .toPromise(); - setIsCustomID(false); + setIsFetchingDocument(false); + setNavDocsIndex(0); if (response) { - setNavDocsIndex(0); setDocuments(response.rawResponse.hits.hits); + } else { + setDocuments([]); } }, [indexPattern, search] @@ -137,7 +157,8 @@ export const FieldPreviewProvider: FunctionComponent = ({ children }) => { const loadDocument = useCallback( async (id: string) => { - setIsCustomID(true); + setPreviewResponse({ fields: [], error: null }); + setIsFetchingDocument(true); const response = await search .search({ @@ -155,13 +176,32 @@ export const FieldPreviewProvider: FunctionComponent = ({ children }) => { }) .toPromise(); + setIsFetchingDocument(false); + setNavDocsIndex(0); + if (response) { if (response.rawResponse.hits.total > 0) { - setNavDocsIndex(0); setDocuments(response.rawResponse.hits.hits); } else { - // TODO: Not found + setDocuments([]); + setPreviewResponse({ + fields: [], + error: { + code: 'DOC_NOT_FOUND', + error: { + message: i18n.translate( + 'indexPatternFieldEditor.fieldPreview.error.documentNotFoundDescription', + { + defaultMessage: + 'Error previewing the field as the document provided was not found.', + } + ), + }, + }, + }); } + } else { + setDocuments([]); } }, [indexPattern, search] @@ -195,7 +235,12 @@ export const FieldPreviewProvider: FunctionComponent = ({ children }) => { const { values, error } = data; if (error) { - setPreviewResponse({ fields: [], error: parseEsError(error, true) }); + const fallBackError = { message: 'Error executing the script.' }; + + setPreviewResponse({ + fields: [], + error: { code: 'PAINLESS_SCRIPT_ERROR', error: parseEsError(error, true) ?? fallBackError }, + }); } else { setPreviewResponse({ fields: [{ key: params.name!, value: values[0] }], @@ -222,12 +267,15 @@ export const FieldPreviewProvider: FunctionComponent = ({ children }) => { () => ({ fields: previewResponse.fields, error: previewResponse.error, - updateParams, + params: { + value: params, + update: updateParams, + }, currentDocument: { value: currentDocument, loadSingle: loadDocument, loadFromCluster: fetchSampleDocuments, - isCustomID, + isLoading: isFetchingDocument, }, navigation: { isFirstDoc: navDocsIndex === 0, @@ -246,11 +294,12 @@ export const FieldPreviewProvider: FunctionComponent = ({ children }) => { }), [ previewResponse, + params, updateParams, currentDocument, loadDocument, fetchSampleDocuments, - isCustomID, + isFetchingDocument, navDocsIndex, totalDocs, goToNextDoc, diff --git a/src/plugins/index_pattern_field_editor/public/components/preview/field_preview_empty_prompt.tsx b/src/plugins/index_pattern_field_editor/public/components/preview/field_preview_empty_prompt.tsx index 21dbdf3df7260..7b39e745749ab 100644 --- a/src/plugins/index_pattern_field_editor/public/components/preview/field_preview_empty_prompt.tsx +++ b/src/plugins/index_pattern_field_editor/public/components/preview/field_preview_empty_prompt.tsx @@ -30,7 +30,7 @@ export const FieldPreviewEmptyPrompt = () => {

{i18n.translate('indexPatternFieldEditor.fieldPreview.emptyPromptDescription', { defaultMessage: - 'Configure field value or format to see a preview of our new fields will appear.', + 'Configure field name and value or format to see a preview of our new fields will appear.', })}

diff --git a/src/plugins/index_pattern_field_editor/public/components/preview/field_preview_error.tsx b/src/plugins/index_pattern_field_editor/public/components/preview/field_preview_error.tsx new file mode 100644 index 0000000000000..2fc3d5885b66b --- /dev/null +++ b/src/plugins/index_pattern_field_editor/public/components/preview/field_preview_error.tsx @@ -0,0 +1,31 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ +import React from 'react'; +import { EuiCallOut } from '@elastic/eui'; + +import { useFieldPreviewContext } from './field_preview_context'; + +export const FieldPreviewError = () => { + const { error } = useFieldPreviewContext(); + + if (error === null) { + return null; + } + + return ( + +

{error.error.message}

+ {error.code === 'PAINLESS_SCRIPT_ERROR' &&

{error.error.reason}

} +
+ ); +}; diff --git a/src/plugins/index_pattern_field_editor/public/components/preview/preview_documents_nav.tsx b/src/plugins/index_pattern_field_editor/public/components/preview/preview_documents_nav.tsx index 7925eb863daff..27fe6fcb3e2fd 100644 --- a/src/plugins/index_pattern_field_editor/public/components/preview/preview_documents_nav.tsx +++ b/src/plugins/index_pattern_field_editor/public/components/preview/preview_documents_nav.tsx @@ -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, { useCallback, useState, useEffect } from 'react'; +import React, { useCallback, useState, useEffect, useRef } from 'react'; import useDebounce from 'react-use/lib/useDebounce'; import { i18n } from '@kbn/i18n'; import { @@ -21,44 +21,64 @@ import { useFieldPreviewContext } from './field_preview_context'; export const PreviewDocumentsNav = () => { const { - currentDocument: { value: currentDocument, loadSingle, loadFromCluster, isCustomID }, + currentDocument: { value: currentDocument, loadSingle, loadFromCluster, isLoading }, navigation: { prev, next }, + error, } = useFieldPreviewContext(); + const lastDocumentLoaded = useRef(null); const [documentId, setDocumentId] = useState(''); + const [isCustomID, setIsCustomID] = useState(false); - const errorMessage = null; - const isInvalid = false; + const errorMessage = + error !== null && error.code === 'DOC_NOT_FOUND' + ? i18n.translate( + 'indexPatternFieldEditor.fieldPreview.documentIdField.documentNotFoundError', + { + defaultMessage: 'Document not found', + } + ) + : null; + const isInvalid = error !== null; // We don't display the nav button when the user has entered a custom // document ID as at that point there is no more reference to what's "next" const showNavButtons = isCustomID === false; - const onDocumentIdChange = useCallback(async (e: React.SyntheticEvent) => { + const onDocumentIdChange = useCallback((e: React.SyntheticEvent) => { + setIsCustomID(true); const nextId = e.currentTarget.value; setDocumentId(nextId); }, []); + const loadDocFromCluster = useCallback(() => { + lastDocumentLoaded.current = null; + setIsCustomID(false); + loadFromCluster(); + }, [loadFromCluster]); + useEffect(() => { - if (currentDocument) { + if (currentDocument && !isCustomID) { setDocumentId(currentDocument._id); } - }, [currentDocument]); + }, [currentDocument, isCustomID]); useDebounce( () => { - if (!Boolean(documentId.trim())) { + if (!isCustomID || !Boolean(documentId.trim())) { return; } - if (documentId === currentDocument?._id) { + if (lastDocumentLoaded.current === documentId) { return; } + lastDocumentLoaded.current = documentId; + loadSingle(documentId); }, 500, - [documentId, currentDocument] + [documentId, isCustomID] ); return ( @@ -76,14 +96,20 @@ export const PreviewDocumentsNav = () => { isInvalid={isInvalid} value={documentId} onChange={onDocumentIdChange} + isLoading={isLoading} fullWidth data-test-subj="documentIdField" /> {isCustomID && ( - loadFromCluster()}> - Load latest documents from cluster + + {i18n.translate( + 'indexPatternFieldEditor.fieldPreview.documentIdField.loadDocumentsFromCluster', + { + defaultMessage: 'Load documents from cluster', + } + )} )} @@ -98,7 +124,12 @@ export const PreviewDocumentsNav = () => { size="m" onClick={prev} iconType="arrowLeft" - aria-label="Previous" + aria-label={i18n.translate( + 'indexPatternFieldEditor.fieldPreview.documentNav.previousArialabel', + { + defaultMessage: 'Previous', + } + )} /> @@ -107,7 +138,12 @@ export const PreviewDocumentsNav = () => { size="m" onClick={next} iconType="arrowRight" - aria-label="Next" + aria-label={i18n.translate( + 'indexPatternFieldEditor.fieldPreview.documentNav.nextArialabel', + { + defaultMessage: 'Next', + } + )} /> From 9648e3eda755242a41298480504636873fce7f9e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Se=CC=81bastien=20Loix?= Date: Wed, 2 Jun 2021 11:17:19 +0100 Subject: [PATCH 04/41] Add field list and field list item --- .../components/flyout_panels/flyout_panel.tsx | 2 +- .../flyout_panels/flyout_panels.scss | 4 ++ .../preview/field_list/field_list.scss | 14 +++++++ .../preview/field_list/field_list.tsx | 42 +++++++++++++++++++ .../preview/field_list/field_list_item.tsx | 30 +++++++++++++ .../components/preview/field_preview.tsx | 6 ++- 6 files changed, 96 insertions(+), 2 deletions(-) create mode 100644 src/plugins/index_pattern_field_editor/public/components/preview/field_list/field_list.scss create mode 100644 src/plugins/index_pattern_field_editor/public/components/preview/field_list/field_list.tsx create mode 100644 src/plugins/index_pattern_field_editor/public/components/preview/field_list/field_list_item.tsx diff --git a/src/plugins/index_pattern_field_editor/public/components/flyout_panels/flyout_panel.tsx b/src/plugins/index_pattern_field_editor/public/components/flyout_panels/flyout_panel.tsx index 8f9193a47327f..a01d71e97e836 100644 --- a/src/plugins/index_pattern_field_editor/public/components/flyout_panels/flyout_panel.tsx +++ b/src/plugins/index_pattern_field_editor/public/components/flyout_panels/flyout_panel.tsx @@ -99,7 +99,7 @@ export const Panel: React.FC> = ({ } return ( - +
{children} diff --git a/src/plugins/index_pattern_field_editor/public/components/flyout_panels/flyout_panels.scss b/src/plugins/index_pattern_field_editor/public/components/flyout_panels/flyout_panels.scss index ec6e84e5d4311..d4fd24914d1c4 100644 --- a/src/plugins/index_pattern_field_editor/public/components/flyout_panels/flyout_panels.scss +++ b/src/plugins/index_pattern_field_editor/public/components/flyout_panels/flyout_panels.scss @@ -1,5 +1,9 @@ .fieldEditor__flyoutPanels { height: 100%; + + &__column { + height: 100%; + } } .fieldEditor__flyoutPanel { diff --git a/src/plugins/index_pattern_field_editor/public/components/preview/field_list/field_list.scss b/src/plugins/index_pattern_field_editor/public/components/preview/field_list/field_list.scss new file mode 100644 index 0000000000000..3e557811e6631 --- /dev/null +++ b/src/plugins/index_pattern_field_editor/public/components/preview/field_list/field_list.scss @@ -0,0 +1,14 @@ +.indexPatternFieldEditor__previewFieldList { + &__item { + border-bottom: $euiBorderThin; + padding-top: $euiSize; + + &__key { + overflow-wrap: anywhere; + } + + &__value { + overflow-wrap: anywhere; + } + } +} diff --git a/src/plugins/index_pattern_field_editor/public/components/preview/field_list/field_list.tsx b/src/plugins/index_pattern_field_editor/public/components/preview/field_list/field_list.tsx new file mode 100644 index 0000000000000..a05d903773725 --- /dev/null +++ b/src/plugins/index_pattern_field_editor/public/components/preview/field_list/field_list.tsx @@ -0,0 +1,42 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ +import React from 'react'; +import { get } from 'lodash'; + +import { useFieldEditorContext } from '../../field_editor_context'; +import { useFieldPreviewContext } from '../field_preview_context'; +import { PreviewListItem } from './field_list_item'; + +import './field_list.scss'; + +export const PreviewFieldList = () => { + const { indexPattern } = useFieldEditorContext(); + const { + currentDocument: { value: currentDocument }, + } = useFieldPreviewContext(); + + const fields = indexPattern.fields.getAll(); + const fieldsValues = fields + .map((field) => ({ + key: field.displayName, + value: JSON.stringify(get(currentDocument?._source, field.name)), + })) + .filter(({ value }) => value !== undefined); + + if (currentDocument === undefined) { + return null; + } + + return ( + <> + {fieldsValues.map((field) => ( + + ))} + + ); +}; diff --git a/src/plugins/index_pattern_field_editor/public/components/preview/field_list/field_list_item.tsx b/src/plugins/index_pattern_field_editor/public/components/preview/field_list/field_list_item.tsx new file mode 100644 index 0000000000000..981228f01a585 --- /dev/null +++ b/src/plugins/index_pattern_field_editor/public/components/preview/field_list/field_list_item.tsx @@ -0,0 +1,30 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React from 'react'; +import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; + +interface Props { + field: { + key: string; + value: string; + }; +} + +export const PreviewListItem: React.FC = ({ field: { key, value } }) => { + return ( + + + {key} + + + {value} + + + ); +}; diff --git a/src/plugins/index_pattern_field_editor/public/components/preview/field_preview.tsx b/src/plugins/index_pattern_field_editor/public/components/preview/field_preview.tsx index 1322193193cc6..43372dd056ac7 100644 --- a/src/plugins/index_pattern_field_editor/public/components/preview/field_preview.tsx +++ b/src/plugins/index_pattern_field_editor/public/components/preview/field_preview.tsx @@ -13,12 +13,14 @@ import { FieldPreviewHeader } from './field_preview_header'; import { FieldPreviewEmptyPrompt } from './field_preview_empty_prompt'; import { PreviewDocumentsNav } from './preview_documents_nav'; import { FieldPreviewError } from './field_preview_error'; +import { PreviewFieldList } from './field_list/field_list'; export const FieldPreview = () => { const { params: { value: { name, script, format }, }, + error, } = useFieldPreviewContext(); // To show the preview we at least need a name to be defined and the script or the format @@ -32,9 +34,11 @@ export const FieldPreview = () => { <> + - + + {error === null ? : } )} From 94aaf37c929f49ddbd8557e7b940f0b0cee5a2fd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Se=CC=81bastien=20Loix?= Date: Wed, 2 Jun 2021 13:25:16 +0100 Subject: [PATCH 05/41] Use virtual list to render fields --- .../preview/field_list/field_list.scss | 18 +++++-- .../preview/field_list/field_list.tsx | 54 ++++++++++++++----- .../preview/field_list/field_list_item.tsx | 16 ++++-- 3 files changed, 65 insertions(+), 23 deletions(-) diff --git a/src/plugins/index_pattern_field_editor/public/components/preview/field_list/field_list.scss b/src/plugins/index_pattern_field_editor/public/components/preview/field_list/field_list.scss index 3e557811e6631..29c33e096ccc8 100644 --- a/src/plugins/index_pattern_field_editor/public/components/preview/field_list/field_list.scss +++ b/src/plugins/index_pattern_field_editor/public/components/preview/field_list/field_list.scss @@ -1,14 +1,22 @@ .indexPatternFieldEditor__previewFieldList { &__item { border-bottom: $euiBorderThin; - padding-top: $euiSize; + height: 64px; + align-items: center; + width: 100%; - &__key { - overflow-wrap: anywhere; + &__key, &__value { + flex-basis: calc(50% - 16px); + display: flex; + flex-shrink: 0; + flex-grow: 0; + overflow: hidden; } - &__value { - overflow-wrap: anywhere; + &__key__wrapper, &__value__wrapper { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; } } } diff --git a/src/plugins/index_pattern_field_editor/public/components/preview/field_list/field_list.tsx b/src/plugins/index_pattern_field_editor/public/components/preview/field_list/field_list.tsx index a05d903773725..66c32a5b3e659 100644 --- a/src/plugins/index_pattern_field_editor/public/components/preview/field_list/field_list.tsx +++ b/src/plugins/index_pattern_field_editor/public/components/preview/field_list/field_list.tsx @@ -5,7 +5,8 @@ * in compliance with, at your election, the Elastic License 2.0 or the Server * Side Public License, v 1. */ -import React from 'react'; +import React, { useMemo } from 'react'; +import VirtualList from 'react-tiny-virtual-list'; import { get } from 'lodash'; import { useFieldEditorContext } from '../../field_editor_context'; @@ -14,29 +15,56 @@ import { PreviewListItem } from './field_list_item'; import './field_list.scss'; +const ITEM_HEIGHT = 64; + export const PreviewFieldList = () => { const { indexPattern } = useFieldEditorContext(); const { currentDocument: { value: currentDocument }, } = useFieldPreviewContext(); - const fields = indexPattern.fields.getAll(); - const fieldsValues = fields - .map((field) => ({ - key: field.displayName, - value: JSON.stringify(get(currentDocument?._source, field.name)), - })) - .filter(({ value }) => value !== undefined); + const { + fields: { getAll: getAllFields }, + } = indexPattern; + + const fields = useMemo(() => { + return getAllFields(); + }, [getAllFields]); + + const fieldsValues = useMemo( + () => + fields + .map((field) => ({ + key: field.displayName, + value: JSON.stringify(get(currentDocument?._source, field.name)), + })) + .filter(({ value }) => value !== undefined), + [fields, currentDocument?._source] + ); if (currentDocument === undefined) { return null; } + const listHeight = Math.min(fieldsValues.length * ITEM_HEIGHT, 600); + return ( - <> - {fieldsValues.map((field) => ( - - ))} - + { + const item = fieldsValues[index]; + + return ( +
+ +
+ ); + }} + /> ); }; diff --git a/src/plugins/index_pattern_field_editor/public/components/preview/field_list/field_list_item.tsx b/src/plugins/index_pattern_field_editor/public/components/preview/field_list/field_list_item.tsx index 981228f01a585..e648589cc4ae0 100644 --- a/src/plugins/index_pattern_field_editor/public/components/preview/field_list/field_list_item.tsx +++ b/src/plugins/index_pattern_field_editor/public/components/preview/field_list/field_list_item.tsx @@ -7,7 +7,7 @@ */ import React from 'react'; -import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; +import { EuiFlexGroup, EuiFlexItem, EuiToolTip } from '@elastic/eui'; interface Props { field: { @@ -20,11 +20,17 @@ export const PreviewListItem: React.FC = ({ field: { key, value } }) => { return ( - {key} - - - {value} +
{key}
+ +
+ {value} +
+
); }; From d265e1b848d68434e37189b55be9b88c8932a8f0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Se=CC=81bastien=20Loix?= Date: Wed, 2 Jun 2021 14:11:51 +0100 Subject: [PATCH 06/41] Expose panel height to consumers --- .../components/flyout_panels/flyout_panel.tsx | 37 +++++++++++++++---- .../flyout_panels/flyout_panels.scss | 2 + .../public/components/flyout_panels/index.ts | 2 + 3 files changed, 33 insertions(+), 8 deletions(-) diff --git a/src/plugins/index_pattern_field_editor/public/components/flyout_panels/flyout_panel.tsx b/src/plugins/index_pattern_field_editor/public/components/flyout_panels/flyout_panel.tsx index a01d71e97e836..d3c33e6cc0560 100644 --- a/src/plugins/index_pattern_field_editor/public/components/flyout_panels/flyout_panel.tsx +++ b/src/plugins/index_pattern_field_editor/public/components/flyout_panels/flyout_panel.tsx @@ -13,20 +13,23 @@ import React, { useCallback, createContext, useContext, + useMemo, } from 'react'; import classnames from 'classnames'; -import { EuiFlexItem } from '@elastic/eui'; +import { EuiFlexItem, EuiResizeObserver } from '@elastic/eui'; import { useFlyoutPanelsContext } from './flyout_panels'; interface Context { registerFooter: () => void; registerContent: () => void; + height: number; } const flyoutPanelContext = createContext({ registerFooter: () => {}, registerContent: () => {}, + height: -1, }); export interface Props { @@ -51,6 +54,8 @@ export const Panel: React.FC> = ({ hasFooter: false, }); + const [panelHeight, setPanelHeight] = useState(-1); + /* eslint-disable @typescript-eslint/naming-convention */ const classes = classnames('fieldEditor__flyoutPanel', className, { 'fieldEditor__flyoutPanel--pageBackground': backgroundColor === 'euiPageBackground', @@ -86,6 +91,16 @@ export const Panel: React.FC> = ({ }); }, []); + const onResize = useCallback(({ height }: { height: number }) => { + setPanelHeight(height); + }, []); + + const ctx = useMemo(() => ({ registerContent, registerFooter, height: panelHeight }), [ + registerFooter, + registerContent, + panelHeight, + ]); + useLayoutEffect(() => { const removePanel = addPanel({ width }); @@ -99,13 +114,19 @@ export const Panel: React.FC> = ({ } return ( - - -
- {children} -
-
-
+ + {(resizeRef) => ( + +
+ +
+ {children} +
+
+
+
+ )} +
); }; diff --git a/src/plugins/index_pattern_field_editor/public/components/flyout_panels/flyout_panels.scss b/src/plugins/index_pattern_field_editor/public/components/flyout_panels/flyout_panels.scss index d4fd24914d1c4..391c7b129be31 100644 --- a/src/plugins/index_pattern_field_editor/public/components/flyout_panels/flyout_panels.scss +++ b/src/plugins/index_pattern_field_editor/public/components/flyout_panels/flyout_panels.scss @@ -10,6 +10,8 @@ height: 100%; overflow-y: auto; padding: $euiSizeL; + display: flex; + flex-direction: column; &--pageBackground { background-color: $euiPageBackgroundColor; diff --git a/src/plugins/index_pattern_field_editor/public/components/flyout_panels/index.ts b/src/plugins/index_pattern_field_editor/public/components/flyout_panels/index.ts index 9fc9a7a916f56..0380a0bfefe72 100644 --- a/src/plugins/index_pattern_field_editor/public/components/flyout_panels/index.ts +++ b/src/plugins/index_pattern_field_editor/public/components/flyout_panels/index.ts @@ -12,6 +12,8 @@ import { PanelContent } from './flyout_panels_content'; import { Panel } from './flyout_panel'; import { Panels } from './flyout_panels'; +export { useFlyoutPanelContext } from './flyout_panel'; + export const FlyoutPanels = { Group: Panels, Item: Panel, From 60579cdfc8a9915f39cd13b4576c8802bb2793c8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Se=CC=81bastien=20Loix?= Date: Wed, 2 Jun 2021 14:13:26 +0100 Subject: [PATCH 07/41] Make virtual field list height dynamic --- .../preview/field_list/field_list.tsx | 16 +++++++++----- .../components/preview/field_preview.tsx | 22 ++++++++++++++++--- .../preview/preview_documents_nav.scss | 4 ++++ .../preview/preview_documents_nav.tsx | 4 +++- 4 files changed, 36 insertions(+), 10 deletions(-) create mode 100644 src/plugins/index_pattern_field_editor/public/components/preview/preview_documents_nav.scss diff --git a/src/plugins/index_pattern_field_editor/public/components/preview/field_list/field_list.tsx b/src/plugins/index_pattern_field_editor/public/components/preview/field_list/field_list.tsx index 66c32a5b3e659..25e07ddd4dbb1 100644 --- a/src/plugins/index_pattern_field_editor/public/components/preview/field_list/field_list.tsx +++ b/src/plugins/index_pattern_field_editor/public/components/preview/field_list/field_list.tsx @@ -17,7 +17,11 @@ import './field_list.scss'; const ITEM_HEIGHT = 64; -export const PreviewFieldList = () => { +interface Props { + height: number; +} + +export const PreviewFieldList: React.FC = ({ height }) => { const { indexPattern } = useFieldEditorContext(); const { currentDocument: { value: currentDocument }, @@ -42,11 +46,11 @@ export const PreviewFieldList = () => { [fields, currentDocument?._source] ); - if (currentDocument === undefined) { + if (currentDocument === undefined || height === -1) { return null; } - const listHeight = Math.min(fieldsValues.length * ITEM_HEIGHT, 600); + const listHeight = Math.min(fieldsValues.length * ITEM_HEIGHT, height); return ( { itemSize={ITEM_HEIGHT} overscanCount={4} renderItem={({ index, style }) => { - const item = fieldsValues[index]; + const field = fieldsValues[index]; return ( -
- +
+
); }} diff --git a/src/plugins/index_pattern_field_editor/public/components/preview/field_preview.tsx b/src/plugins/index_pattern_field_editor/public/components/preview/field_preview.tsx index 43372dd056ac7..521d50b75c7a1 100644 --- a/src/plugins/index_pattern_field_editor/public/components/preview/field_preview.tsx +++ b/src/plugins/index_pattern_field_editor/public/components/preview/field_preview.tsx @@ -5,8 +5,8 @@ * in compliance with, at your election, the Elastic License 2.0 or the Server * Side Public License, v 1. */ -import React from 'react'; -import { EuiSpacer } from '@elastic/eui'; +import React, { useState, useCallback } from 'react'; +import { EuiSpacer, EuiResizeObserver } from '@elastic/eui'; import { useFieldPreviewContext } from './field_preview_context'; import { FieldPreviewHeader } from './field_preview_header'; @@ -16,6 +16,8 @@ import { FieldPreviewError } from './field_preview_error'; import { PreviewFieldList } from './field_list/field_list'; export const FieldPreview = () => { + const [fieldListHeight, setFieldListHeight] = useState(-1); + const { params: { value: { name, script, format }, @@ -26,6 +28,10 @@ export const FieldPreview = () => { // To show the preview we at least need a name to be defined and the script or the format const isEmptyPromptVisible = name === null || (script !== null && format !== null); + const onFieldListResize = useCallback(({ height }: { height: number }) => { + setFieldListHeight(height); + }, []); + return ( <> {isEmptyPromptVisible ? ( @@ -38,7 +44,17 @@ export const FieldPreview = () => { - {error === null ? : } + {error === null ? ( + + {(resizeRef) => ( +
+ +
+ )} +
+ ) : ( + + )} )} diff --git a/src/plugins/index_pattern_field_editor/public/components/preview/preview_documents_nav.scss b/src/plugins/index_pattern_field_editor/public/components/preview/preview_documents_nav.scss new file mode 100644 index 0000000000000..5dc3e4897cbdd --- /dev/null +++ b/src/plugins/index_pattern_field_editor/public/components/preview/preview_documents_nav.scss @@ -0,0 +1,4 @@ +.indexPatternFieldEditor__documentsNav { + flex-grow: 0; + flex-shrink: 0; +} diff --git a/src/plugins/index_pattern_field_editor/public/components/preview/preview_documents_nav.tsx b/src/plugins/index_pattern_field_editor/public/components/preview/preview_documents_nav.tsx index 27fe6fcb3e2fd..1408fce253156 100644 --- a/src/plugins/index_pattern_field_editor/public/components/preview/preview_documents_nav.tsx +++ b/src/plugins/index_pattern_field_editor/public/components/preview/preview_documents_nav.tsx @@ -19,6 +19,8 @@ import { import { useFieldPreviewContext } from './field_preview_context'; +import './preview_documents_nav.scss'; + export const PreviewDocumentsNav = () => { const { currentDocument: { value: currentDocument, loadSingle, loadFromCluster, isLoading }, @@ -82,7 +84,7 @@ export const PreviewDocumentsNav = () => { ); return ( - + Date: Fri, 4 Jun 2021 12:32:20 +0100 Subject: [PATCH 08/41] Move filtering of fields to Preview component --- .../preview/field_list/field_list.tsx | 40 +++------ .../components/preview/field_preview.tsx | 86 ++++++++++++++++--- 2 files changed, 88 insertions(+), 38 deletions(-) diff --git a/src/plugins/index_pattern_field_editor/public/components/preview/field_list/field_list.tsx b/src/plugins/index_pattern_field_editor/public/components/preview/field_list/field_list.tsx index 25e07ddd4dbb1..a8abad8923a3d 100644 --- a/src/plugins/index_pattern_field_editor/public/components/preview/field_list/field_list.tsx +++ b/src/plugins/index_pattern_field_editor/public/components/preview/field_list/field_list.tsx @@ -5,63 +5,47 @@ * in compliance with, at your election, the Elastic License 2.0 or the Server * Side Public License, v 1. */ -import React, { useMemo } from 'react'; +import React from 'react'; import VirtualList from 'react-tiny-virtual-list'; -import { get } from 'lodash'; -import { useFieldEditorContext } from '../../field_editor_context'; import { useFieldPreviewContext } from '../field_preview_context'; import { PreviewListItem } from './field_list_item'; import './field_list.scss'; -const ITEM_HEIGHT = 64; +export const ITEM_HEIGHT = 64; + +export interface Field { + key: string; + value: string; +} interface Props { + fields: Field[]; height: number; } -export const PreviewFieldList: React.FC = ({ height }) => { - const { indexPattern } = useFieldEditorContext(); +export const PreviewFieldList: React.FC = ({ height, fields }) => { const { currentDocument: { value: currentDocument }, } = useFieldPreviewContext(); - const { - fields: { getAll: getAllFields }, - } = indexPattern; - - const fields = useMemo(() => { - return getAllFields(); - }, [getAllFields]); - - const fieldsValues = useMemo( - () => - fields - .map((field) => ({ - key: field.displayName, - value: JSON.stringify(get(currentDocument?._source, field.name)), - })) - .filter(({ value }) => value !== undefined), - [fields, currentDocument?._source] - ); - if (currentDocument === undefined || height === -1) { return null; } - const listHeight = Math.min(fieldsValues.length * ITEM_HEIGHT, height); + const listHeight = Math.min(fields.length * ITEM_HEIGHT, height); return ( { - const field = fieldsValues[index]; + const field = fields[index]; return (
diff --git a/src/plugins/index_pattern_field_editor/public/components/preview/field_preview.tsx b/src/plugins/index_pattern_field_editor/public/components/preview/field_preview.tsx index 521d50b75c7a1..2ffd7725602dd 100644 --- a/src/plugins/index_pattern_field_editor/public/components/preview/field_preview.tsx +++ b/src/plugins/index_pattern_field_editor/public/components/preview/field_preview.tsx @@ -5,33 +5,85 @@ * in compliance with, at your election, the Elastic License 2.0 or the Server * Side Public License, v 1. */ -import React, { useState, useCallback } from 'react'; -import { EuiSpacer, EuiResizeObserver } from '@elastic/eui'; +import React, { useState, useCallback, useMemo } from 'react'; +import { get } from 'lodash'; +import { i18n } from '@kbn/i18n'; +import { EuiSpacer, EuiResizeObserver, EuiButtonEmpty } from '@elastic/eui'; +import { useFieldEditorContext } from '../field_editor_context'; import { useFieldPreviewContext } from './field_preview_context'; import { FieldPreviewHeader } from './field_preview_header'; import { FieldPreviewEmptyPrompt } from './field_preview_empty_prompt'; import { PreviewDocumentsNav } from './preview_documents_nav'; import { FieldPreviewError } from './field_preview_error'; -import { PreviewFieldList } from './field_list/field_list'; +import { PreviewFieldList, Field, ITEM_HEIGHT } from './field_list/field_list'; + +const INITIAL_MAX_NUMBER_OF_FIELDS = 7; export const FieldPreview = () => { + const { indexPattern } = useFieldEditorContext(); + const [fieldListHeight, setFieldListHeight] = useState(-1); + const [showAllFields, setShowAllFields] = useState(false); const { params: { value: { name, script, format }, }, + currentDocument: { value: currentDocument }, error, } = useFieldPreviewContext(); + const { + fields: { getAll: getAllFields }, + } = indexPattern; + // To show the preview we at least need a name to be defined and the script or the format const isEmptyPromptVisible = name === null || (script !== null && format !== null); + const fields = useMemo(() => { + return getAllFields(); + }, [getAllFields]); + + const fieldsValues: Field[] = useMemo( + () => + fields + .map((field) => ({ + key: field.displayName, + value: JSON.stringify(get(currentDocument?._source, field.name)), + })) + .filter(({ value }) => value !== undefined), + [fields, currentDocument?._source] + ); + + const filteredFields = useMemo( + () => + showAllFields + ? fieldsValues + : fieldsValues.filter((_, i) => i < INITIAL_MAX_NUMBER_OF_FIELDS), + [fieldsValues, showAllFields] + ); + const onFieldListResize = useCallback(({ height }: { height: number }) => { setFieldListHeight(height); }, []); + const toggleShowAllFields = useCallback(() => { + setShowAllFields((prev) => !prev); + }, []); + + const renderToggleFieldsButton = () => ( + + {showAllFields + ? i18n.translate('indexPatternFieldEditor.fieldPreview.showLessFieldsButtonLabel', { + defaultMessage: 'Show less', + }) + : i18n.translate('indexPatternFieldEditor.fieldPreview.showMoreFieldsButtonLabel', { + defaultMessage: 'Show more', + })} + + ); + return ( <> {isEmptyPromptVisible ? ( @@ -45,13 +97,27 @@ export const FieldPreview = () => { {error === null ? ( - - {(resizeRef) => ( -
- -
- )} -
+ filteredFields.length > 0 && ( + <> + + {(resizeRef) => ( +
+ +
+ )} +
+
{renderToggleFieldsButton()}
+ + ) ) : ( )} From 17c9a22d01961472fa4a586f7bd7bfdf047db026 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Se=CC=81bastien=20Loix?= Date: Fri, 4 Jun 2021 15:12:47 +0100 Subject: [PATCH 09/41] Render current field being created --- .../preview/field_list/field_list.scss | 5 ++ .../preview/field_list/field_list_item.tsx | 12 ++- .../components/preview/field_preview.scss | 3 + .../components/preview/field_preview.tsx | 73 +++++++++++++------ 4 files changed, 67 insertions(+), 26 deletions(-) create mode 100644 src/plugins/index_pattern_field_editor/public/components/preview/field_preview.scss diff --git a/src/plugins/index_pattern_field_editor/public/components/preview/field_list/field_list.scss b/src/plugins/index_pattern_field_editor/public/components/preview/field_list/field_list.scss index 29c33e096ccc8..e0fd1a98abf53 100644 --- a/src/plugins/index_pattern_field_editor/public/components/preview/field_list/field_list.scss +++ b/src/plugins/index_pattern_field_editor/public/components/preview/field_list/field_list.scss @@ -5,6 +5,11 @@ align-items: center; width: 100%; + &--highlighted { + $backgroundColor: tintOrShade($euiColorWarning, 90%, 70%); + background: $backgroundColor; + } + &__key, &__value { flex-basis: calc(50% - 16px); display: flex; diff --git a/src/plugins/index_pattern_field_editor/public/components/preview/field_list/field_list_item.tsx b/src/plugins/index_pattern_field_editor/public/components/preview/field_list/field_list_item.tsx index e648589cc4ae0..2b26fe6016961 100644 --- a/src/plugins/index_pattern_field_editor/public/components/preview/field_list/field_list_item.tsx +++ b/src/plugins/index_pattern_field_editor/public/components/preview/field_list/field_list_item.tsx @@ -7,6 +7,7 @@ */ import React from 'react'; +import classnames from 'classnames'; import { EuiFlexGroup, EuiFlexItem, EuiToolTip } from '@elastic/eui'; interface Props { @@ -14,11 +15,18 @@ interface Props { key: string; value: string; }; + highlighted?: boolean; } -export const PreviewListItem: React.FC = ({ field: { key, value } }) => { +export const PreviewListItem: React.FC = ({ field: { key, value }, highlighted }) => { + /* eslint-disable @typescript-eslint/naming-convention */ + const classes = classnames('indexPatternFieldEditor__previewFieldList__item', { + 'indexPatternFieldEditor__previewFieldList__item--highlighted': highlighted, + }); + /* eslint-enable @typescript-eslint/naming-convention */ + return ( - +
{key}
diff --git a/src/plugins/index_pattern_field_editor/public/components/preview/field_preview.scss b/src/plugins/index_pattern_field_editor/public/components/preview/field_preview.scss new file mode 100644 index 0000000000000..22a21102fc835 --- /dev/null +++ b/src/plugins/index_pattern_field_editor/public/components/preview/field_preview.scss @@ -0,0 +1,3 @@ +.indexPatternFieldEditor__previewPinnedFields { + margin-bottom: $euiSizeL; +} diff --git a/src/plugins/index_pattern_field_editor/public/components/preview/field_preview.tsx b/src/plugins/index_pattern_field_editor/public/components/preview/field_preview.tsx index 2ffd7725602dd..0aab9643d6a02 100644 --- a/src/plugins/index_pattern_field_editor/public/components/preview/field_preview.tsx +++ b/src/plugins/index_pattern_field_editor/public/components/preview/field_preview.tsx @@ -16,8 +16,11 @@ import { FieldPreviewHeader } from './field_preview_header'; import { FieldPreviewEmptyPrompt } from './field_preview_empty_prompt'; import { PreviewDocumentsNav } from './preview_documents_nav'; import { FieldPreviewError } from './field_preview_error'; +import { PreviewListItem } from './field_list/field_list_item'; import { PreviewFieldList, Field, ITEM_HEIGHT } from './field_list/field_list'; +import './field_preview.scss'; + const INITIAL_MAX_NUMBER_OF_FIELDS = 7; export const FieldPreview = () => { @@ -30,6 +33,7 @@ export const FieldPreview = () => { params: { value: { name, script, format }, }, + fields, currentDocument: { value: currentDocument }, error, } = useFieldPreviewContext(); @@ -41,19 +45,19 @@ export const FieldPreview = () => { // To show the preview we at least need a name to be defined and the script or the format const isEmptyPromptVisible = name === null || (script !== null && format !== null); - const fields = useMemo(() => { + const indexPatternFields = useMemo(() => { return getAllFields(); }, [getAllFields]); const fieldsValues: Field[] = useMemo( () => - fields + indexPatternFields .map((field) => ({ key: field.displayName, value: JSON.stringify(get(currentDocument?._source, field.name)), })) .filter(({ value }) => value !== undefined), - [fields, currentDocument?._source] + [indexPatternFields, currentDocument?._source] ); const filteredFields = useMemo( @@ -72,6 +76,21 @@ export const FieldPreview = () => { setShowAllFields((prev) => !prev); }, []); + const renderPinnedFields = () => { + if (fields.length === 0) { + return null; + } + + return ( +
+ +
+ ); + }; + const renderToggleFieldsButton = () => ( {showAllFields @@ -97,27 +116,33 @@ export const FieldPreview = () => { {error === null ? ( - filteredFields.length > 0 && ( - <> - - {(resizeRef) => ( -
- -
- )} -
-
{renderToggleFieldsButton()}
- - ) + <> + {/* The current field(s) the user is creating and fields he decided to pin */} + {renderPinnedFields()} + + {/* List of other fields in the document */} + {filteredFields.length > 0 && ( + <> + + {(resizeRef) => ( +
+ +
+ )} +
+
{renderToggleFieldsButton()}
+ + )} + ) : ( )} From 9f88c226f02c71863f1cd7ca2668435ae052d661 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Se=CC=81bastien=20Loix?= Date: Fri, 4 Jun 2021 15:53:43 +0100 Subject: [PATCH 10/41] Add updating preview indicator in header --- .../components/field_editor/field_editor.tsx | 11 ---- .../components/preview/field_preview.tsx | 8 ++- .../preview/field_preview_context.tsx | 9 +++ .../preview/field_preview_header.tsx | 58 +++++++++++++++---- 4 files changed, 61 insertions(+), 25 deletions(-) diff --git a/src/plugins/index_pattern_field_editor/public/components/field_editor/field_editor.tsx b/src/plugins/index_pattern_field_editor/public/components/field_editor/field_editor.tsx index 3f22424b19f51..1f386ed80887e 100644 --- a/src/plugins/index_pattern_field_editor/public/components/field_editor/field_editor.tsx +++ b/src/plugins/index_pattern_field_editor/public/components/field_editor/field_editor.tsx @@ -155,8 +155,6 @@ const FieldEditorComponent = ({ field, onChange, syntaxError }: Props) => { fieldTypeToProcess, } = useFieldEditorContext(); const { - fields, - error, params: { update: updatePreviewParams }, panel: { setIsVisible: setIsPanelVisible }, } = useFieldPreviewContext(); @@ -194,15 +192,6 @@ const FieldEditorComponent = ({ field, onChange, syntaxError }: Props) => { clearSyntaxError(); }, [type, clearSyntaxError]); - useEffect(() => { - // TODO: remove console.log - if (error) { - console.log('Preview error', error); // eslint-disable-line no-console - } else { - console.log('Field preview:', JSON.stringify(fields[0], null, 4)); // eslint-disable-line no-console - } - }, [fields, error]); - useEffect(() => { updatePreviewParams({ name: Boolean(updatedName?.trim()) ? updatedName : null, diff --git a/src/plugins/index_pattern_field_editor/public/components/preview/field_preview.tsx b/src/plugins/index_pattern_field_editor/public/components/preview/field_preview.tsx index 0aab9643d6a02..9729c156f4287 100644 --- a/src/plugins/index_pattern_field_editor/public/components/preview/field_preview.tsx +++ b/src/plugins/index_pattern_field_editor/public/components/preview/field_preview.tsx @@ -42,8 +42,12 @@ export const FieldPreview = () => { fields: { getAll: getAllFields }, } = indexPattern; - // To show the preview we at least need a name to be defined and the script or the format - const isEmptyPromptVisible = name === null || (script !== null && format !== null); + // To show the preview we at least need a name to be defined, the script or the format + // and a response from the _execute API + const isEmptyPromptVisible = + name === null || + (script === null && format === null) || + (fields.length === 0 && error === null); const indexPatternFields = useMemo(() => { return getAllFields(); diff --git a/src/plugins/index_pattern_field_editor/public/components/preview/field_preview_context.tsx b/src/plugins/index_pattern_field_editor/public/components/preview/field_preview_context.tsx index c2528ca2db107..9330656d2c3af 100644 --- a/src/plugins/index_pattern_field_editor/public/components/preview/field_preview_context.tsx +++ b/src/plugins/index_pattern_field_editor/public/components/preview/field_preview_context.tsx @@ -48,6 +48,7 @@ interface Context { value: Params; update: (updated: Partial) => void; }; + isLoadingPreview: boolean; currentDocument: { value?: EsDocument; loadSingle: (id: string) => Promise; @@ -107,6 +108,8 @@ export const FieldPreviewProvider: FunctionComponent = ({ children }) => { const [isPanelVisible, setIsPanelVisible] = useState(false); /** 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); /** Define if we provide the document to preview from the cluster or from a custom JSON */ const [from, setFrom] = useState('cluster'); @@ -212,6 +215,8 @@ export const FieldPreviewProvider: FunctionComponent = ({ children }) => { return; } + setIsLoadingPreview(true); + const response = await getFieldPreview({ index: currentDocIndex, document: params.document!, @@ -219,6 +224,8 @@ export const FieldPreviewProvider: FunctionComponent = ({ children }) => { script: params.script!, }); + setIsLoadingPreview(false); + const { error: serverError } = response; if (serverError) { @@ -267,6 +274,7 @@ export const FieldPreviewProvider: FunctionComponent = ({ children }) => { () => ({ fields: previewResponse.fields, error: previewResponse.error, + isLoadingPreview, params: { value: params, update: updateParams, @@ -295,6 +303,7 @@ export const FieldPreviewProvider: FunctionComponent = ({ children }) => { [ previewResponse, params, + isLoadingPreview, updateParams, currentDocument, loadDocument, diff --git a/src/plugins/index_pattern_field_editor/public/components/preview/field_preview_header.tsx b/src/plugins/index_pattern_field_editor/public/components/preview/field_preview_header.tsx index 716e911951f31..1201b412f8166 100644 --- a/src/plugins/index_pattern_field_editor/public/components/preview/field_preview_header.tsx +++ b/src/plugins/index_pattern_field_editor/public/components/preview/field_preview_header.tsx @@ -7,7 +7,14 @@ */ import React from 'react'; -import { EuiTitle, EuiText, EuiTextColor } from '@elastic/eui'; +import { + EuiTitle, + EuiText, + EuiTextColor, + EuiFlexGroup, + EuiFlexItem, + EuiLoadingSpinner, +} from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { useFieldEditorContext } from '../field_editor_context'; @@ -17,28 +24,55 @@ const i18nTexts = { customData: i18n.translate('indexPatternFieldEditor.fieldPreview.subTitle.customData', { defaultMessage: 'Custom data', }), + updatingLabel: i18n.translate('indexPatternFieldEditor.fieldPreview.updatingPreviewLabel', { + defaultMessage: 'Updating...', + }), }; export const FieldPreviewHeader = () => { const { indexPattern } = useFieldEditorContext(); - const { from } = useFieldPreviewContext(); + const { + from, + isLoadingPreview, + currentDocument: { isLoading }, + } = useFieldPreviewContext(); + + const isUpdating = isLoadingPreview || isLoading; + return ( - <> - -

- {i18n.translate('indexPatternFieldEditor.fieldPreview.title', { - defaultMessage: 'Preview', - })} -

-
+
+ + + +

+ {i18n.translate('indexPatternFieldEditor.fieldPreview.title', { + defaultMessage: 'Preview', + })} +

+
+
+ + {isUpdating && ( + + + + + + {i18nTexts.updatingLabel} + + + )} +
{i18n.translate('indexPatternFieldEditor.fieldPreview.subTitle', { defaultMessage: 'From: {from}', - values: { from: from.value === 'cluster' ? indexPattern.title : i18nTexts.customData }, + values: { + from: from.value === 'cluster' ? indexPattern.title : i18nTexts.customData, + }, })} - +
); }; From a8d79a0d87547f9b20258e28e116a96c4744ff92 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Se=CC=81bastien=20Loix?= Date: Mon, 7 Jun 2021 13:20:54 +0100 Subject: [PATCH 11/41] Update field list rendering --- .../components/flyout_panels/flyout_panel.tsx | 35 ++--- .../flyout_panels/flyout_panels.scss | 3 +- ...ents_nav.tsx => documents_nav_preview.tsx} | 130 +++++++++--------- .../preview/field_list/field_list.scss | 34 ++++- .../preview/field_list/field_list.tsx | 107 ++++++++++---- .../preview/field_list/field_list_item.tsx | 18 ++- .../components/preview/field_preview.scss | 6 +- .../components/preview/field_preview.tsx | 113 ++++----------- .../preview/field_preview_context.tsx | 15 +- .../preview/preview_documents_nav.scss | 4 - 10 files changed, 241 insertions(+), 224 deletions(-) rename src/plugins/index_pattern_field_editor/public/components/preview/{preview_documents_nav.tsx => documents_nav_preview.tsx} (54%) delete mode 100644 src/plugins/index_pattern_field_editor/public/components/preview/preview_documents_nav.scss diff --git a/src/plugins/index_pattern_field_editor/public/components/flyout_panels/flyout_panel.tsx b/src/plugins/index_pattern_field_editor/public/components/flyout_panels/flyout_panel.tsx index d3c33e6cc0560..62e518b7fd91c 100644 --- a/src/plugins/index_pattern_field_editor/public/components/flyout_panels/flyout_panel.tsx +++ b/src/plugins/index_pattern_field_editor/public/components/flyout_panels/flyout_panel.tsx @@ -16,20 +16,18 @@ import React, { useMemo, } from 'react'; import classnames from 'classnames'; -import { EuiFlexItem, EuiResizeObserver } from '@elastic/eui'; +import { EuiFlexItem } from '@elastic/eui'; import { useFlyoutPanelsContext } from './flyout_panels'; interface Context { registerFooter: () => void; registerContent: () => void; - height: number; } const flyoutPanelContext = createContext({ registerFooter: () => {}, registerContent: () => {}, - height: -1, }); export interface Props { @@ -54,8 +52,6 @@ export const Panel: React.FC> = ({ hasFooter: false, }); - const [panelHeight, setPanelHeight] = useState(-1); - /* eslint-disable @typescript-eslint/naming-convention */ const classes = classnames('fieldEditor__flyoutPanel', className, { 'fieldEditor__flyoutPanel--pageBackground': backgroundColor === 'euiPageBackground', @@ -91,14 +87,9 @@ export const Panel: React.FC> = ({ }); }, []); - const onResize = useCallback(({ height }: { height: number }) => { - setPanelHeight(height); - }, []); - - const ctx = useMemo(() => ({ registerContent, registerFooter, height: panelHeight }), [ + const ctx = useMemo(() => ({ registerContent, registerFooter }), [ registerFooter, registerContent, - panelHeight, ]); useLayoutEffect(() => { @@ -110,23 +101,17 @@ export const Panel: React.FC> = ({ const styles: CSSProperties = {}; if (width) { - styles.flexBasis = `${width}%`; + styles.minWidth = `${width}%`; } return ( - - {(resizeRef) => ( - -
- -
- {children} -
-
-
-
- )} -
+ + +
+ {children} +
+
+
); }; diff --git a/src/plugins/index_pattern_field_editor/public/components/flyout_panels/flyout_panels.scss b/src/plugins/index_pattern_field_editor/public/components/flyout_panels/flyout_panels.scss index 391c7b129be31..e1339904d3f0e 100644 --- a/src/plugins/index_pattern_field_editor/public/components/flyout_panels/flyout_panels.scss +++ b/src/plugins/index_pattern_field_editor/public/components/flyout_panels/flyout_panels.scss @@ -3,6 +3,7 @@ &__column { height: 100%; + overflow: hidden; } } @@ -10,8 +11,6 @@ height: 100%; overflow-y: auto; padding: $euiSizeL; - display: flex; - flex-direction: column; &--pageBackground { background-color: $euiPageBackgroundColor; diff --git a/src/plugins/index_pattern_field_editor/public/components/preview/preview_documents_nav.tsx b/src/plugins/index_pattern_field_editor/public/components/preview/documents_nav_preview.tsx similarity index 54% rename from src/plugins/index_pattern_field_editor/public/components/preview/preview_documents_nav.tsx rename to src/plugins/index_pattern_field_editor/public/components/preview/documents_nav_preview.tsx index 1408fce253156..5e734d2791f2d 100644 --- a/src/plugins/index_pattern_field_editor/public/components/preview/preview_documents_nav.tsx +++ b/src/plugins/index_pattern_field_editor/public/components/preview/documents_nav_preview.tsx @@ -19,9 +19,7 @@ import { import { useFieldPreviewContext } from './field_preview_context'; -import './preview_documents_nav.scss'; - -export const PreviewDocumentsNav = () => { +export const DocumentsNavPreview = () => { const { currentDocument: { value: currentDocument, loadSingle, loadFromCluster, isLoading }, navigation: { prev, next }, @@ -84,73 +82,75 @@ export const PreviewDocumentsNav = () => { ); return ( - - - - + + + - - {isCustomID && ( - - - {i18n.translate( - 'indexPatternFieldEditor.fieldPreview.documentIdField.loadDocumentsFromCluster', - { - defaultMessage: 'Load documents from cluster', - } - )} - - - )} - - - {showNavButtons && ( - - - - - - - + + + {isCustomID && ( + + + {i18n.translate( + 'indexPatternFieldEditor.fieldPreview.documentIdField.loadDocumentsFromCluster', { - defaultMessage: 'Next', + defaultMessage: 'Load documents from cluster', } )} - /> - - +
+ + )} - )} -
+ + {showNavButtons && ( + + + + + + + + + + + )} +
+
); }; diff --git a/src/plugins/index_pattern_field_editor/public/components/preview/field_list/field_list.scss b/src/plugins/index_pattern_field_editor/public/components/preview/field_list/field_list.scss index e0fd1a98abf53..ccba5945f7ee7 100644 --- a/src/plugins/index_pattern_field_editor/public/components/preview/field_list/field_list.scss +++ b/src/plugins/index_pattern_field_editor/public/components/preview/field_list/field_list.scss @@ -1,9 +1,20 @@ +/** +[1] This corresponds to the ITEM_HEIGHT declared in "field_list.tsx" +[2] This corresponds to the SHOW_MORE_HEIGHT declared in "field_list.tsx" +[3] We need the tooltip to be 100% to display the text ellipsis of the field value +*/ + +$previewFieldItemHeight: 64px; /* [1] */ +$previewShowMoreHeight: 48px; /* [2] */ + .indexPatternFieldEditor__previewFieldList { + position: relative; + &__item { border-bottom: $euiBorderThin; - height: 64px; + height: $previewFieldItemHeight; align-items: center; - width: 100%; + overflow: hidden; &--highlighted { $backgroundColor: tintOrShade($euiColorWarning, 90%, 70%); @@ -11,17 +22,28 @@ } &__key, &__value { - flex-basis: calc(50% - 16px); - display: flex; - flex-shrink: 0; - flex-grow: 0; overflow: hidden; } + &__value .euiToolTipAnchor { + width: 100%; /* [3] */ + } + &__key__wrapper, &__value__wrapper { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; + display: block; + width: 100%; } } + + &__showMore { + position: absolute; + width: 100%; + height: $previewShowMoreHeight; + bottom: $previewShowMoreHeight * -1; + display: flex; + align-items: flex-end; + } } diff --git a/src/plugins/index_pattern_field_editor/public/components/preview/field_list/field_list.tsx b/src/plugins/index_pattern_field_editor/public/components/preview/field_list/field_list.tsx index a8abad8923a3d..df0ccbfb66335 100644 --- a/src/plugins/index_pattern_field_editor/public/components/preview/field_list/field_list.tsx +++ b/src/plugins/index_pattern_field_editor/public/components/preview/field_list/field_list.tsx @@ -5,15 +5,21 @@ * in compliance with, at your election, the Elastic License 2.0 or the Server * Side Public License, v 1. */ -import React from 'react'; +import React, { useState, useMemo, useCallback } from 'react'; import VirtualList from 'react-tiny-virtual-list'; +import { i18n } from '@kbn/i18n'; +import { get } from 'lodash'; +import { EuiButtonEmpty } from '@elastic/eui'; +import { useFieldEditorContext } from '../../field_editor_context'; import { useFieldPreviewContext } from '../field_preview_context'; import { PreviewListItem } from './field_list_item'; import './field_list.scss'; -export const ITEM_HEIGHT = 64; +const ITEM_HEIGHT = 64; +const SHOW_MORE_HEIGHT = 48; +const INITIAL_MAX_NUMBER_OF_FIELDS = 7; export interface Field { key: string; @@ -21,38 +27,93 @@ export interface Field { } interface Props { - fields: Field[]; height: number; } -export const PreviewFieldList: React.FC = ({ height, fields }) => { +export const PreviewFieldList: React.FC = ({ height }) => { + const { indexPattern } = useFieldEditorContext(); const { currentDocument: { value: currentDocument }, } = useFieldPreviewContext(); + const [showAllFields, setShowAllFields] = useState(false); + + const { + fields: { getAll: getAllFields }, + } = indexPattern; + + const indexPatternFields = useMemo(() => { + return getAllFields(); + }, [getAllFields]); + + const fieldsValues: Field[] = useMemo( + () => + indexPatternFields + .map((field) => ({ + key: field.displayName, + value: JSON.stringify(get(currentDocument?._source, field.name)), + })) + .filter(({ value }) => value !== undefined), + [indexPatternFields, currentDocument?._source] + ); + + const filteredFields = useMemo( + () => + showAllFields + ? fieldsValues + : fieldsValues.filter((_, i) => i < INITIAL_MAX_NUMBER_OF_FIELDS), + [fieldsValues, showAllFields] + ); + + // "height" corresponds to the total height of the flex item that occupies the remaining + // vertical space up to the bottom of the flyout panel. We don't want to give that height + // to the virtual list because it would mean that the "Show more" button would be pinned to the + // bottom of the panel all the time. Which is not what we want when we render initially a few + // fields. + const listHeight = Math.min(filteredFields.length * ITEM_HEIGHT, height - SHOW_MORE_HEIGHT); + + const toggleShowAllFields = useCallback(() => { + setShowAllFields((prev) => !prev); + }, []); + + const renderToggleFieldsButton = () => ( + + {showAllFields + ? i18n.translate('indexPatternFieldEditor.fieldPreview.showLessFieldsButtonLabel', { + defaultMessage: 'Show less', + }) + : i18n.translate('indexPatternFieldEditor.fieldPreview.showMoreFieldsButtonLabel', { + defaultMessage: 'Show more', + })} + + ); + if (currentDocument === undefined || height === -1) { return null; } - const listHeight = Math.min(fields.length * ITEM_HEIGHT, height); - return ( - { - const field = fields[index]; - - return ( -
- -
- ); - }} - /> +
+ { + const field = filteredFields[index]; + + return ( +
+ +
+ ); + }} + /> +
+ {renderToggleFieldsButton()} +
+
); }; diff --git a/src/plugins/index_pattern_field_editor/public/components/preview/field_list/field_list_item.tsx b/src/plugins/index_pattern_field_editor/public/components/preview/field_list/field_list_item.tsx index 2b26fe6016961..8494beddd3f04 100644 --- a/src/plugins/index_pattern_field_editor/public/components/preview/field_list/field_list_item.tsx +++ b/src/plugins/index_pattern_field_editor/public/components/preview/field_list/field_list_item.tsx @@ -26,19 +26,17 @@ export const PreviewListItem: React.FC = ({ field: { key, value }, highli /* eslint-enable @typescript-eslint/naming-convention */ return ( - +
{key}
- -
- {value} -
-
+ + + + {value} + + +
); }; diff --git a/src/plugins/index_pattern_field_editor/public/components/preview/field_preview.scss b/src/plugins/index_pattern_field_editor/public/components/preview/field_preview.scss index 22a21102fc835..937e65528fc99 100644 --- a/src/plugins/index_pattern_field_editor/public/components/preview/field_preview.scss +++ b/src/plugins/index_pattern_field_editor/public/components/preview/field_preview.scss @@ -1,3 +1,5 @@ -.indexPatternFieldEditor__previewPinnedFields { - margin-bottom: $euiSizeL; +.indexPatternFieldEditor__previewPannel { + display: flex; + flex-direction: column; + height: 100%; } diff --git a/src/plugins/index_pattern_field_editor/public/components/preview/field_preview.tsx b/src/plugins/index_pattern_field_editor/public/components/preview/field_preview.tsx index 9729c156f4287..b9bbfeebf8976 100644 --- a/src/plugins/index_pattern_field_editor/public/components/preview/field_preview.tsx +++ b/src/plugins/index_pattern_field_editor/public/components/preview/field_preview.tsx @@ -5,88 +5,51 @@ * in compliance with, at your election, the Elastic License 2.0 or the Server * Side Public License, v 1. */ -import React, { useState, useCallback, useMemo } from 'react'; -import { get } from 'lodash'; -import { i18n } from '@kbn/i18n'; -import { EuiSpacer, EuiResizeObserver, EuiButtonEmpty } from '@elastic/eui'; +import React, { useState, useCallback } from 'react'; +import { EuiSpacer, EuiResizeObserver } from '@elastic/eui'; -import { useFieldEditorContext } from '../field_editor_context'; import { useFieldPreviewContext } from './field_preview_context'; import { FieldPreviewHeader } from './field_preview_header'; import { FieldPreviewEmptyPrompt } from './field_preview_empty_prompt'; -import { PreviewDocumentsNav } from './preview_documents_nav'; +import { DocumentsNavPreview } from './documents_nav_preview'; import { FieldPreviewError } from './field_preview_error'; import { PreviewListItem } from './field_list/field_list_item'; -import { PreviewFieldList, Field, ITEM_HEIGHT } from './field_list/field_list'; +import { PreviewFieldList } from './field_list/field_list'; import './field_preview.scss'; -const INITIAL_MAX_NUMBER_OF_FIELDS = 7; - export const FieldPreview = () => { - const { indexPattern } = useFieldEditorContext(); - const [fieldListHeight, setFieldListHeight] = useState(-1); - const [showAllFields, setShowAllFields] = useState(false); const { params: { value: { name, script, format }, }, + previewCount, fields, - currentDocument: { value: currentDocument }, error, } = useFieldPreviewContext(); - const { - fields: { getAll: getAllFields }, - } = indexPattern; - // To show the preview we at least need a name to be defined, the script or the format - // and a response from the _execute API + // and an first response from the _execute API const isEmptyPromptVisible = - name === null || - (script === null && format === null) || - (fields.length === 0 && error === null); - - const indexPatternFields = useMemo(() => { - return getAllFields(); - }, [getAllFields]); - - const fieldsValues: Field[] = useMemo( - () => - indexPatternFields - .map((field) => ({ - key: field.displayName, - value: JSON.stringify(get(currentDocument?._source, field.name)), - })) - .filter(({ value }) => value !== undefined), - [indexPatternFields, currentDocument?._source] - ); - - const filteredFields = useMemo( - () => - showAllFields - ? fieldsValues - : fieldsValues.filter((_, i) => i < INITIAL_MAX_NUMBER_OF_FIELDS), - [fieldsValues, showAllFields] - ); + previewCount === 0 + ? true + : error !== null || fields.length > 0 + ? false + : name === null || (script === null && format === null); const onFieldListResize = useCallback(({ height }: { height: number }) => { setFieldListHeight(height); }, []); - const toggleShowAllFields = useCallback(() => { - setShowAllFields((prev) => !prev); - }, []); - const renderPinnedFields = () => { if (fields.length === 0) { return null; } return ( -
+
{ ); }; - const renderToggleFieldsButton = () => ( - - {showAllFields - ? i18n.translate('indexPatternFieldEditor.fieldPreview.showLessFieldsButtonLabel', { - defaultMessage: 'Show less', - }) - : i18n.translate('indexPatternFieldEditor.fieldPreview.showMoreFieldsButtonLabel', { - defaultMessage: 'Show more', - })} - - ); - return ( - <> +
{isEmptyPromptVisible ? ( ) : ( @@ -116,42 +67,32 @@ export const FieldPreview = () => { - + {error === null ? ( <> - {/* The current field(s) the user is creating and fields he decided to pin */} + {/* The current field(s) the user is creating and fields he has pinned to the top */} {renderPinnedFields()} {/* List of other fields in the document */} - {filteredFields.length > 0 && ( - <> - - {(resizeRef) => ( -
- -
- )} -
-
{renderToggleFieldsButton()}
- - )} + + {(resizeRef) => ( +
+ +
+ )} +
) : ( )} )} - +
); }; diff --git a/src/plugins/index_pattern_field_editor/public/components/preview/field_preview_context.tsx b/src/plugins/index_pattern_field_editor/public/components/preview/field_preview_context.tsx index 9330656d2c3af..b3461a71693de 100644 --- a/src/plugins/index_pattern_field_editor/public/components/preview/field_preview_context.tsx +++ b/src/plugins/index_pattern_field_editor/public/components/preview/field_preview_context.tsx @@ -13,6 +13,7 @@ import React, { useMemo, useCallback, useEffect, + useRef, FunctionComponent, } from 'react'; import useDebounce from 'react-use/lib/useDebounce'; @@ -44,6 +45,8 @@ interface Params { interface Context { fields: Array<{ key: string; value: unknown }>; error: PreviewError | null; + // The preview count will help us decide when to display the empty prompt + previewCount: number; params: { value: Params; update: (updated: Partial) => void; @@ -83,6 +86,7 @@ const defaultParams: Params = { }; export const FieldPreviewProvider: FunctionComponent = ({ children }) => { + const previewCount = useRef(0); const { indexPattern, fieldTypeToProcess, @@ -134,6 +138,7 @@ export const FieldPreviewProvider: FunctionComponent = ({ children }) => { async (limit = 50) => { setPreviewResponse({ fields: [], error: null }); setIsFetchingDocument(true); + setIsLoadingPreview(true); const response = await search .search({ @@ -224,6 +229,7 @@ export const FieldPreviewProvider: FunctionComponent = ({ children }) => { script: params.script!, }); + previewCount.current = ++previewCount.current; setIsLoadingPreview(false); const { error: serverError } = response; @@ -242,7 +248,11 @@ export const FieldPreviewProvider: FunctionComponent = ({ children }) => { const { values, error } = data; if (error) { - const fallBackError = { message: 'Error executing the script.' }; + const fallBackError = { + message: i18n.translate('indexPatternFieldEditor.fieldPreview.defaultErrorTitle', { + defaultMessage: 'Error executing the script.', + }), + }; setPreviewResponse({ fields: [], @@ -261,6 +271,7 @@ export const FieldPreviewProvider: FunctionComponent = ({ children }) => { setNavDocsIndex(0); } setNavDocsIndex((prev) => prev + 1); + setIsLoadingPreview(true); }, [navDocsIndex, totalDocs]); const goToPrevDoc = useCallback(() => { @@ -268,6 +279,7 @@ export const FieldPreviewProvider: FunctionComponent = ({ children }) => { setNavDocsIndex(totalDocs - 1); } setNavDocsIndex((prev) => prev - 1); + setIsLoadingPreview(true); }, [navDocsIndex, totalDocs]); const ctx = useMemo( @@ -275,6 +287,7 @@ export const FieldPreviewProvider: FunctionComponent = ({ children }) => { fields: previewResponse.fields, error: previewResponse.error, isLoadingPreview, + previewCount: previewCount.current, params: { value: params, update: updateParams, diff --git a/src/plugins/index_pattern_field_editor/public/components/preview/preview_documents_nav.scss b/src/plugins/index_pattern_field_editor/public/components/preview/preview_documents_nav.scss deleted file mode 100644 index 5dc3e4897cbdd..0000000000000 --- a/src/plugins/index_pattern_field_editor/public/components/preview/preview_documents_nav.scss +++ /dev/null @@ -1,4 +0,0 @@ -.indexPatternFieldEditor__documentsNav { - flex-grow: 0; - flex-shrink: 0; -} From 6732fdd9d7b7b75985b95a9be1925b11c994d252 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Se=CC=81bastien=20Loix?= Date: Mon, 7 Jun 2021 13:21:25 +0100 Subject: [PATCH 12/41] Add focus to the field editor flyout --- src/plugins/index_pattern_field_editor/public/open_editor.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/plugins/index_pattern_field_editor/public/open_editor.tsx b/src/plugins/index_pattern_field_editor/public/open_editor.tsx index 6b3f577fde2f2..44194ba8a3806 100644 --- a/src/plugins/index_pattern_field_editor/public/open_editor.tsx +++ b/src/plugins/index_pattern_field_editor/public/open_editor.tsx @@ -118,7 +118,7 @@ export const getFieldEditorOpener = ({ /> ), - { className: euiFlyoutClassname, maxWidth: 708, size: 'l' } + { className: euiFlyoutClassname, maxWidth: 708, size: 'l', ownFocus: true } ); return closeEditor; From c31b315c7ef8e96c42734dc3967e06d31274f5ca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Se=CC=81bastien=20Loix?= Date: Mon, 7 Jun 2021 13:49:10 +0100 Subject: [PATCH 13/41] Fix tests --- .../field_editor.helpers.ts | 23 +++++++++------ .../client_integration/field_editor.test.tsx | 28 +++++++++++-------- .../field_editor_flyout_content.helpers.ts | 24 ++++++++++------ .../field_editor_flyout_content.test.ts | 18 ++++++------ .../helpers/setup_environment.tsx | 20 ++++++++++++- 5 files changed, 73 insertions(+), 40 deletions(-) diff --git a/src/plugins/index_pattern_field_editor/__jest__/client_integration/field_editor.helpers.ts b/src/plugins/index_pattern_field_editor/__jest__/client_integration/field_editor.helpers.ts index d733965be2fcd..0d58b2ce89358 100644 --- a/src/plugins/index_pattern_field_editor/__jest__/client_integration/field_editor.helpers.ts +++ b/src/plugins/index_pattern_field_editor/__jest__/client_integration/field_editor.helpers.ts @@ -5,7 +5,7 @@ * in compliance with, at your election, the Elastic License 2.0 or the Server * Side Public License, v 1. */ - +import { act } from 'react-dom/test-utils'; import { registerTestBed, TestBed } from '@kbn/test/jest'; import { Context } from '../../public/components/field_editor_context'; @@ -22,16 +22,21 @@ export const defaultProps: Props = { export type FieldEditorTestBed = TestBed & { actions: ReturnType }; -export const setup = (props?: Partial, deps?: Partial) => { - const testBed = registerTestBed(WithFieldEditorDependencies(FieldEditor, deps), { - memoryRouter: { - wrapComponent: false, - }, - })({ ...defaultProps, ...props }) as TestBed; +export const setup = async (props?: Partial, deps?: Partial) => { + let testBed: TestBed; + + await act(async () => { + testBed = await registerTestBed(WithFieldEditorDependencies(FieldEditor, deps), { + memoryRouter: { + wrapComponent: false, + }, + })({ ...defaultProps, ...props }); + }); + testBed!.component.update(); const actions = { - ...getCommonActions(testBed), + ...getCommonActions(testBed!), }; - return { ...testBed, actions }; + return { ...testBed!, actions }; }; diff --git a/src/plugins/index_pattern_field_editor/__jest__/client_integration/field_editor.test.tsx b/src/plugins/index_pattern_field_editor/__jest__/client_integration/field_editor.test.tsx index 111516e85f34c..d7bb8ec6280e5 100644 --- a/src/plugins/index_pattern_field_editor/__jest__/client_integration/field_editor.test.tsx +++ b/src/plugins/index_pattern_field_editor/__jest__/client_integration/field_editor.test.tsx @@ -78,8 +78,8 @@ describe('', () => { httpRequestsMockHelpers.setFieldPreviewResponse({ message: 'TODO: set by Jest test' }); }); - test('initial state should have "set custom label", "set value" and "set format" turned off', () => { - testBed = setup(); + test('initial state should have "set custom label", "set value" and "set format" turned off', async () => { + testBed = await setup(); ['customLabel', 'value', 'format'].forEach((row) => { const testSubj = `${row}Row.toggle`; @@ -102,7 +102,7 @@ describe('', () => { script: { source: 'emit("hello")' }, }; - testBed = setup({ onChange, field }); + testBed = await setup({ onChange, field }); expect(onChange).toHaveBeenCalled(); @@ -123,7 +123,7 @@ describe('', () => { describe('validation', () => { test('should accept an optional list of existing fields and prevent creating duplicates', async () => { const existingFields = ['myRuntimeField']; - testBed = setup( + testBed = await setup( { onChange, }, @@ -164,7 +164,7 @@ describe('', () => { script: { source: 'emit("hello"' }, }; - testBed = setup( + testBed = await setup( { field, onChange, @@ -214,15 +214,19 @@ describe('', () => { ); }; - const customTestbed = registerTestBed(WithFieldEditorDependencies(TestComponent), { - memoryRouter: { - wrapComponent: false, - }, - })() as TestBed; + let customTestbed: TestBed; + + await act(async () => { + customTestbed = await registerTestBed(WithFieldEditorDependencies(TestComponent), { + memoryRouter: { + wrapComponent: false, + }, + })(); + }); testBed = { - ...customTestbed, - actions: getCommonActions(customTestbed), + ...customTestbed!, + actions: getCommonActions(customTestbed!), }; const { diff --git a/src/plugins/index_pattern_field_editor/__jest__/client_integration/field_editor_flyout_content.helpers.ts b/src/plugins/index_pattern_field_editor/__jest__/client_integration/field_editor_flyout_content.helpers.ts index c05d1135310dc..b73c400b751d2 100644 --- a/src/plugins/index_pattern_field_editor/__jest__/client_integration/field_editor_flyout_content.helpers.ts +++ b/src/plugins/index_pattern_field_editor/__jest__/client_integration/field_editor_flyout_content.helpers.ts @@ -5,7 +5,7 @@ * in compliance with, at your election, the Elastic License 2.0 or the Server * Side Public License, v 1. */ - +import { act } from 'react-dom/test-utils'; import { registerTestBed, TestBed } from '@kbn/test/jest'; import { Context } from '../../public/components/field_editor_context'; @@ -22,16 +22,22 @@ const defaultProps: Props = { isSavingField: false, }; -export const setup = (props?: Partial, deps?: Partial) => { - const testBed = registerTestBed(WithFieldEditorDependencies(FieldEditorFlyoutContent, deps), { - memoryRouter: { - wrapComponent: false, - }, - })({ ...defaultProps, ...props }) as TestBed; +export const setup = async (props?: Partial, deps?: Partial) => { + let testBed: TestBed; + + await act(async () => { + testBed = await registerTestBed(WithFieldEditorDependencies(FieldEditorFlyoutContent, deps), { + memoryRouter: { + wrapComponent: false, + }, + })({ ...defaultProps, ...props }); + }); + + testBed!.component.update(); const actions = { - ...getCommonActions(testBed), + ...getCommonActions(testBed!), }; - return { ...testBed, actions }; + return { ...testBed!, actions }; }; diff --git a/src/plugins/index_pattern_field_editor/__jest__/client_integration/field_editor_flyout_content.test.ts b/src/plugins/index_pattern_field_editor/__jest__/client_integration/field_editor_flyout_content.test.ts index 87b497b61d71a..a4c03131bc3b6 100644 --- a/src/plugins/index_pattern_field_editor/__jest__/client_integration/field_editor_flyout_content.test.ts +++ b/src/plugins/index_pattern_field_editor/__jest__/client_integration/field_editor_flyout_content.test.ts @@ -27,13 +27,13 @@ describe('', () => { httpRequestsMockHelpers.setFieldPreviewResponse({ values: ['Set by Jest test'] }); }); - test('should have the correct title', () => { - const { exists, find } = setup(); + test('should have the correct title', async () => { + const { exists, find } = await setup(); expect(exists('flyoutTitle')).toBe(true); expect(find('flyoutTitle').text()).toBe('Create field'); }); - test('should allow a field to be provided', () => { + test('should allow a field to be provided', async () => { const field = { name: 'foo', type: 'ip', @@ -42,7 +42,7 @@ describe('', () => { }, }; - const { find } = setup({ field }); + const { find } = await setup({ field }); expect(find('flyoutTitle').text()).toBe(`Edit field 'foo'`); expect(find('nameField.input').props().value).toBe(field.name); @@ -58,7 +58,7 @@ describe('', () => { }; const onSave: jest.Mock = jest.fn(); - const { find } = setup({ onSave, field }); + const { find } = await setup({ onSave, field }); await act(async () => { find('fieldSaveButton').simulate('click'); @@ -75,9 +75,9 @@ describe('', () => { expect(fieldReturned).toEqual(field); }); - test('should accept an onCancel prop', () => { + test('should accept an onCancel prop', async () => { const onCancel = jest.fn(); - const { find } = setup({ onCancel }); + const { find } = await setup({ onCancel }); find('closeFlyoutButton').simulate('click'); @@ -88,7 +88,7 @@ describe('', () => { test('should validate the fields and prevent saving invalid form', async () => { const onSave: jest.Mock = jest.fn(); - const { find, exists, form, component } = setup({ onSave }); + const { find, exists, form, component } = await setup({ onSave }); expect(find('fieldSaveButton').props().disabled).toBe(false); @@ -117,7 +117,7 @@ describe('', () => { component, form, actions: { toggleFormRow, changeFieldType }, - } = setup({ onSave }); + } = await setup({ onSave }); act(() => { form.setInputValue('nameField.input', 'someName'); diff --git a/src/plugins/index_pattern_field_editor/__jest__/client_integration/helpers/setup_environment.tsx b/src/plugins/index_pattern_field_editor/__jest__/client_integration/helpers/setup_environment.tsx index 71f7900930bf4..c051dc42a57b2 100644 --- a/src/plugins/index_pattern_field_editor/__jest__/client_integration/helpers/setup_environment.tsx +++ b/src/plugins/index_pattern_field_editor/__jest__/client_integration/helpers/setup_environment.tsx @@ -44,12 +44,30 @@ export const setupEnvironment = () => { }; }; +export const indexPatternFields = [ + { + name: 'field1', + displayName: 'field1', + }, + { + name: 'field2', + displayName: 'field2', + }, + { + name: 'field3', + displayName: 'field3', + }, +]; + export const WithFieldEditorDependencies = ( Comp: FunctionComponent, overridingDependencies?: Partial ) => (props: T) => { const dependencies: Context = { - indexPattern: { title: 'testIndexPattern' } as any, + indexPattern: { + title: 'testIndexPattern', + fields: { getAll: () => indexPatternFields }, + } as any, uiSettings: {} as any, fieldTypeToProcess: 'runtime', existingConcreteFields: [], From e5395a08dc42a27d2df9dce8e6c30d2987a2fc1d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Se=CC=81bastien=20Loix?= Date: Tue, 8 Jun 2021 16:16:40 +0100 Subject: [PATCH 14/41] Set fixed width to panel and move preview to the right --- .../field_editor_flyout_content.tsx | 18 +++---- .../components/flyout_panels/flyout_panel.tsx | 27 +++++++--- .../flyout_panels/flyout_panels.tsx | 49 ++++++++++++++----- 3 files changed, 64 insertions(+), 30 deletions(-) diff --git a/src/plugins/index_pattern_field_editor/public/components/field_editor_flyout_content.tsx b/src/plugins/index_pattern_field_editor/public/components/field_editor_flyout_content.tsx index 67bba9c9bfc2b..0b650e4b0560c 100644 --- a/src/plugins/index_pattern_field_editor/public/components/field_editor_flyout_content.tsx +++ b/src/plugins/index_pattern_field_editor/public/components/field_editor_flyout_content.tsx @@ -203,16 +203,9 @@ const FieldEditorFlyoutContentComponent = ({ return ( <> - - {/* Preview panel */} - {isPanelVisible && ( - - - - )} - + {/* Editor panel */} - + @@ -290,6 +283,13 @@ const FieldEditorFlyoutContentComponent = ({ + + {/* Preview panel */} + {isPanelVisible && ( + + + + )} {modal} diff --git a/src/plugins/index_pattern_field_editor/public/components/flyout_panels/flyout_panel.tsx b/src/plugins/index_pattern_field_editor/public/components/flyout_panels/flyout_panel.tsx index 62e518b7fd91c..580c5f55885f0 100644 --- a/src/plugins/index_pattern_field_editor/public/components/flyout_panels/flyout_panel.tsx +++ b/src/plugins/index_pattern_field_editor/public/components/flyout_panels/flyout_panel.tsx @@ -31,7 +31,7 @@ const flyoutPanelContext = createContext({ }); export interface Props { - /** Width of the panel (in percent %) */ + /** Width of the panel (in percent % or in px if the "fixedPanelWidths" prop is set to true on the panels group) */ width?: number; /** EUI sass background */ backgroundColor?: 'euiPageBackground' | 'euiEmptyShade'; @@ -52,6 +52,8 @@ export const Panel: React.FC> = ({ hasFooter: false, }); + const [styles, setStyles] = useState({}); + /* eslint-disable @typescript-eslint/naming-convention */ const classes = classnames('fieldEditor__flyoutPanel', className, { 'fieldEditor__flyoutPanel--pageBackground': backgroundColor === 'euiPageBackground', @@ -93,17 +95,26 @@ export const Panel: React.FC> = ({ ]); useLayoutEffect(() => { - const removePanel = addPanel({ width }); + const { removePanel, isFixedWidth } = addPanel({ width }); + + if (width) { + setStyles((prev) => { + if (isFixedWidth) { + return { + ...prev, + width: `${width}px`, + }; + } + return { + ...prev, + minWidth: `${width}%`, + }; + }); + } return removePanel; }, [width, addPanel]); - const styles: CSSProperties = {}; - - if (width) { - styles.minWidth = `${width}%`; - } - return ( diff --git a/src/plugins/index_pattern_field_editor/public/components/flyout_panels/flyout_panels.tsx b/src/plugins/index_pattern_field_editor/public/components/flyout_panels/flyout_panels.tsx index c878090ec7c1b..61ef1adf899d2 100644 --- a/src/plugins/index_pattern_field_editor/public/components/flyout_panels/flyout_panels.tsx +++ b/src/plugins/index_pattern_field_editor/public/components/flyout_panels/flyout_panels.tsx @@ -23,7 +23,7 @@ interface Panel { } interface Context { - addPanel: (panel: Panel) => () => void; + addPanel: (panel: Panel) => { removePanel: () => void; isFixedWidth: boolean }; } let idx = 0; @@ -32,7 +32,10 @@ const panelId = () => idx++; const flyoutPanelsContext = createContext({ addPanel() { - return () => {}; + return { + removePanel: () => {}, + isFixedWidth: false, + }; }, }); @@ -46,9 +49,16 @@ export interface Props { flyoutClassName: string; /** The size between the panels. Corresponds to EuiFlexGroup gutterSize */ gutterSize?: EuiFlexGroupProps['gutterSize']; + /** Flag to indicate if the panels width are declared as fixed pixel width instead of percent */ + fixedPanelWidths?: boolean; } -export const Panels: React.FC = ({ maxWidth, flyoutClassName, ...props }) => { +export const Panels: React.FC = ({ + maxWidth, + flyoutClassName, + fixedPanelWidths = false, + ...props +}) => { const flyoutDOMelement = useMemo(() => { const el = document.getElementsByClassName(flyoutClassName); @@ -75,9 +85,13 @@ export const Panels: React.FC = ({ maxWidth, flyoutClassName, ...props }) setPanels((prev) => { return { ...prev, [nextId]: panel }; }); - return removePanel.bind(null, nextId); + + return { + removePanel: removePanel.bind(null, nextId), + isFixedWidth: fixedPanelWidths, + }; }, - [removePanel] + [removePanel, fixedPanelWidths] ); const ctx: Context = useMemo( @@ -92,14 +106,23 @@ export const Panels: React.FC = ({ maxWidth, flyoutClassName, ...props }) return; } - const totalPercentWidth = Math.min( - 100, - Object.values(panels).reduce((acc, { width = 0 }) => acc + width, 0) - ); - const currentWidth = (maxWidth * totalPercentWidth) / 100; - - flyoutDOMelement.style.maxWidth = `${currentWidth}px`; - }, [panels, maxWidth, flyoutClassName, flyoutDOMelement]); + let currentWidth: number; + + if (fixedPanelWidths) { + const totalWidth = Object.values(panels).reduce((acc, { width = 0 }) => acc + width, 0); + currentWidth = Math.min(maxWidth, totalWidth); + flyoutDOMelement.style.width = `${currentWidth}px`; + flyoutDOMelement.style.minWidth = `${currentWidth}px`; + flyoutDOMelement.style.maxWidth = `${currentWidth}px`; + } else { + const totalPercentWidth = Math.min( + 100, + Object.values(panels).reduce((acc, { width = 0 }) => acc + width, 0) + ); + currentWidth = (maxWidth * totalPercentWidth) / 100; + flyoutDOMelement.style.maxWidth = `${currentWidth}px`; + } + }, [panels, maxWidth, fixedPanelWidths, flyoutClassName, flyoutDOMelement]); return ( From f9a6fad4880a76c5fcdc7eb2f73e37508756277a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Se=CC=81bastien=20Loix?= Date: Tue, 15 Jun 2021 14:54:11 +0100 Subject: [PATCH 15/41] [Form lib] Add useFormIsModified() hook --- .../forms/docs/core/use_form_is_modified.mdx | 43 +++++++ .../components/use_field.test.tsx | 120 +++++++++++++++++- .../forms/hook_form_lib/form_context.tsx | 10 +- .../static/forms/hook_form_lib/hooks/index.ts | 1 + .../forms/hook_form_lib/hooks/use_field.ts | 63 +++------ .../hooks/use_form_is_modified.test.tsx | 62 +++++++++ .../hooks/use_form_is_modified.ts | 49 +++++++ .../static/forms/hook_form_lib/index.ts | 6 +- .../static/forms/hook_form_lib/types.ts | 2 + 9 files changed, 308 insertions(+), 48 deletions(-) create mode 100644 src/plugins/es_ui_shared/static/forms/docs/core/use_form_is_modified.mdx create mode 100644 src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/use_form_is_modified.test.tsx create mode 100644 src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/use_form_is_modified.ts diff --git a/src/plugins/es_ui_shared/static/forms/docs/core/use_form_is_modified.mdx b/src/plugins/es_ui_shared/static/forms/docs/core/use_form_is_modified.mdx new file mode 100644 index 0000000000000..bcf32ad59652e --- /dev/null +++ b/src/plugins/es_ui_shared/static/forms/docs/core/use_form_is_modified.mdx @@ -0,0 +1,43 @@ +--- +id: formLibCoreUseFormIsModified +slug: /form-lib/core/use-form-is-modified +title: useFormIsModified() +summary: Know when your form has been modified by the user +tags: ['forms', 'kibana', 'dev'] +date: 2021-06-15 +--- + +**Returns:** `boolean` + +There might be cases where you need to know if the form has been modified by the user. For example: the user is about to leave the form after making some changes, you might want to show a modal indicating that the changes will be lost. + +For that you can use the `useFormIsModified` hook which will update each time any of the field value changes. If the user makes a change and then undoes the change and puts the initial value back, the form **won't be marked** as modified. + +## Options + +### form + +**Type:** `FormHook` + +The form hook object. It is only required to provide the form hook object in your **root form component**. + +```js +const RootFormComponent = () => { + // root form component, where the form object is declared + const { form } = useForm(); + const isModified = useFormIsModified({ form }); + + return ( +
+ + + ); +}; + +const ChildComponent = () => { + const isModified = useFormIsModified(); // no need to provide the form object + return ( +
...
+ ); +}; +``` diff --git a/src/plugins/es_ui_shared/static/forms/hook_form_lib/components/use_field.test.tsx b/src/plugins/es_ui_shared/static/forms/hook_form_lib/components/use_field.test.tsx index 72990808e61a9..46b7fd9a4cd19 100644 --- a/src/plugins/es_ui_shared/static/forms/hook_form_lib/components/use_field.test.tsx +++ b/src/plugins/es_ui_shared/static/forms/hook_form_lib/components/use_field.test.tsx @@ -10,7 +10,7 @@ import React, { useEffect, FunctionComponent } from 'react'; import { act } from 'react-dom/test-utils'; import { registerTestBed, TestBed } from '../shared_imports'; -import { FormHook, OnUpdateHandler, FieldConfig } from '../types'; +import { FormHook, OnUpdateHandler, FieldConfig, FieldHook } from '../types'; import { useForm } from '../hooks/use_form'; import { Form } from './form'; import { UseField } from './use_field'; @@ -54,6 +54,124 @@ describe('', () => { }); }); + describe('state', () => { + describe('isPristine, isDirty, isModified', () => { + // Dummy component to handle object type data + const ObjectField: React.FC<{ field: FieldHook }> = ({ field: { value, setValue } }) => { + const onFieldChange = (e: React.ChangeEvent) => { + // Make sure to set the field value to an **object** + setValue(JSON.parse(e.target.value)); + }; + + return ; + }; + + const objectInitialValue = { initial: 'value' }; + + interface PristineDirtyModifiedState { + isModified: boolean; + isDirty: boolean; + isPristine: boolean; + } + + const getChildrenFunc = ( + cb: (state: PristineDirtyModifiedState) => void, + Component?: React.ComponentType<{ field: FieldHook }> + ) => (field: FieldHook) => { + const { onChange, isModified, isPristine, isDirty } = field; + + // Forward the field state to our jest.fn() spy + cb({ isModified, isPristine, isDirty }); + + // Render the child component if any (useful to test the Object field type) + return Component ? ( + + ) : ( + + ); + }; + + interface Props { + fieldProps: Record; + } + + const TestComp = ({ fieldProps }: Props) => { + const { form } = useForm(); + return ( +
+ + + ); + }; + + const onIsModifiedChange = jest.fn(); + const lastValue = (): PristineDirtyModifiedState => + onIsModifiedChange.mock.calls[onIsModifiedChange.mock.calls.length - 1][0]; + + const setup = registerTestBed(TestComp, { + defaultProps: { onIsModifiedChange }, + memoryRouter: { wrapComponent: false }, + }); + + [ + { + description: 'should update the state for field without default values', + initialValue: '', + changedValue: 'changed', + fieldProps: { children: getChildrenFunc(onIsModifiedChange) }, + }, + { + description: 'should update the state for field with default value in their config', + initialValue: 'initialValue', + changedValue: 'changed', + fieldProps: { + children: getChildrenFunc(onIsModifiedChange), + config: { defaultValue: 'initialValue' }, + }, + }, + { + description: 'should update the state for field with default value passed through props', + initialValue: 'initialValue', + changedValue: 'changed', + fieldProps: { + children: getChildrenFunc(onIsModifiedChange), + defaultValue: 'initialValue', + }, + }, + // "Object" field type must be JSON.serialized to compare old and new value + // this test makes sure this is done and "isModified" is indeed "false" when + // putting back the original object + { + description: 'should update the state for field with object field type', + initialValue: JSON.stringify(objectInitialValue), + changedValue: JSON.stringify({ foo: 'bar' }), + fieldProps: { + children: getChildrenFunc(onIsModifiedChange, ObjectField), + defaultValue: objectInitialValue, + }, + }, + ].forEach(({ description, fieldProps, initialValue, changedValue }) => { + test(description, async () => { + const { form } = await setup({ fieldProps }); + + expect(lastValue()).toEqual({ isPristine: true, isDirty: false, isModified: false }); + + await act(async () => { + form.setInputValue('testField', changedValue); + }); + + expect(lastValue()).toEqual({ isPristine: false, isDirty: true, isModified: true }); + + // Put back to the initial value --> isModified should be false + await act(async () => { + form.setInputValue('testField', initialValue); + }); + expect(lastValue()).toEqual({ isPristine: false, isDirty: true, isModified: false }); + }); + }); + }); + }); + describe('validation', () => { let formHook: FormHook | null = null; diff --git a/src/plugins/es_ui_shared/static/forms/hook_form_lib/form_context.tsx b/src/plugins/es_ui_shared/static/forms/hook_form_lib/form_context.tsx index 79affc8c31a72..b733c2285fa89 100644 --- a/src/plugins/es_ui_shared/static/forms/hook_form_lib/form_context.tsx +++ b/src/plugins/es_ui_shared/static/forms/hook_form_lib/form_context.tsx @@ -21,9 +21,15 @@ export const FormProvider = ({ children, form }: Props) => ( {children} ); -export const useFormContext = function () { +interface Options { + throwIfNotFound?: boolean; +} + +export const useFormContext = function ({ + throwIfNotFound = true, +}: Options = {}) { const context = useContext(FormContext) as FormHook; - if (context === undefined) { + if (throwIfNotFound && context === undefined) { throw new Error('useFormContext must be used within a '); } return context; diff --git a/src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/index.ts b/src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/index.ts index 3e1e72d4ed5f0..3afb5bf6a20c2 100644 --- a/src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/index.ts +++ b/src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/index.ts @@ -9,3 +9,4 @@ export { useField, InternalFieldConfig } from './use_field'; export { useForm } from './use_form'; export { useFormData } from './use_form_data'; +export { useFormIsModified } from './use_form_is_modified'; diff --git a/src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/use_field.ts b/src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/use_field.ts index 0cf1bb3601667..7a897a44b8fad 100644 --- a/src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/use_field.ts +++ b/src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/use_field.ts @@ -33,7 +33,7 @@ export const useField = ( const { type = FIELD_TYPES.TEXT, defaultValue = '', // The value to use a fallback mecanism when no initial value is passed - initialValue = config.defaultValue ?? '', // The value explicitly passed + initialValue = config.defaultValue ?? (('' as unknown) as I), // The value explicitly passed isIncludedInOutput = true, label = '', labelAppend = '', @@ -69,6 +69,7 @@ export const useField = ( const [value, setStateValue] = useState(deserializeValue); const [errors, setStateErrors] = useState([]); const [isPristine, setPristine] = useState(true); + const [isModified, setIsModified] = useState(false); const [isValidating, setValidating] = useState(false); const [isChangingValue, setIsChangingValue] = useState(false); const [isValidated, setIsValidated] = useState(false); @@ -126,6 +127,12 @@ export const useField = ( const changeIteration = ++changeCounter.current; const startTime = Date.now(); + setIsModified(() => { + if (typeof value === 'object') { + return JSON.stringify(value) !== JSON.stringify(initialValue); + } + return value !== initialValue; + }); setPristine(false); setIsChangingValue(true); @@ -169,6 +176,7 @@ export const useField = ( }, [ path, value, + initialValue, valueChangeListener, valueChangeDebounceTime, fieldsToValidateOnChange, @@ -456,58 +464,26 @@ export const useField = ( [errors] ); - /** - * Handler to update the state and make sure the component is still mounted. - * When resetting the form, some field might get unmounted (e.g. a toggle on "true" becomes "false" and now certain fields should not be in the DOM). - * In that scenario there is a race condition in the "reset" method below, because the useState() hook is not synchronous. - * - * A better approach would be to have the state in a reducer and being able to update all values in a single dispatch action. - */ - const updateStateIfMounted = useCallback( - ( - state: 'isPristine' | 'isValidating' | 'isChangingValue' | 'isValidated' | 'errors' | 'value', - nextValue: any - ) => { - if (isMounted.current === false) { - return; - } - - switch (state) { - case 'value': - return setValue(nextValue); - case 'errors': - return setStateErrors(nextValue); - case 'isChangingValue': - return setIsChangingValue(nextValue); - case 'isPristine': - return setPristine(nextValue); - case 'isValidated': - return setIsValidated(nextValue); - case 'isValidating': - return setValidating(nextValue); - } - }, - [setValue] - ); - const reset: FieldHook['reset'] = useCallback( (resetOptions = { resetValue: true }) => { const { resetValue = true, defaultValue: updatedDefaultValue } = resetOptions; - updateStateIfMounted('isPristine', true); - updateStateIfMounted('isValidating', false); - updateStateIfMounted('isChangingValue', false); - updateStateIfMounted('isValidated', false); - updateStateIfMounted('errors', []); + setPristine(true); + setIsModified(false); + setValidating(false); + setIsChangingValue(false); + setIsValidated(false); + setStateErrors([]); if (resetValue) { hasBeenReset.current = true; const newValue = deserializeValue(updatedDefaultValue ?? defaultValue); - updateStateIfMounted('value', newValue); + // updateStateIfMounted('value', newValue); + setValue(newValue); return newValue; } }, - [updateStateIfMounted, deserializeValue, defaultValue] + [deserializeValue, defaultValue, setValue, setStateErrors] ); // Don't take into account non blocker validation. Some are just warning (like trying to add a wrong ComboBox item) @@ -523,6 +499,8 @@ export const useField = ( value, errors, isPristine, + isDirty: !isPristine, + isModified, isValid, isValidating, isValidated, @@ -545,6 +523,7 @@ export const useField = ( helpText, value, isPristine, + isModified, errors, isValid, isValidating, diff --git a/src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/use_form_is_modified.test.tsx b/src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/use_form_is_modified.test.tsx new file mode 100644 index 0000000000000..c24ea1f69bf10 --- /dev/null +++ b/src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/use_form_is_modified.test.tsx @@ -0,0 +1,62 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ +import React from 'react'; +import { act } from 'react-dom/test-utils'; + +import { registerTestBed } from '../shared_imports'; +import { useForm } from './use_form'; +import { useFormIsModified } from './use_form_is_modified'; +import { Form } from '../components/form'; +import { UseField } from '../components/use_field'; + +describe('useFormIsModified()', () => { + const TestComp = ({ + onIsModifiedChange, + }: { + onIsModifiedChange: (isModified: boolean) => void; + }) => { + const { form } = useForm(); + const isModified = useFormIsModified({ form }); + + // Call our jest.spy() with the latest hook value + onIsModifiedChange(isModified); + + return ( +
+ + + ); + }; + + const onIsModifiedChange = jest.fn(); + const lastValue = () => + onIsModifiedChange.mock.calls[onIsModifiedChange.mock.calls.length - 1][0]; + + const setup = registerTestBed(TestComp, { + defaultProps: { onIsModifiedChange }, + memoryRouter: { wrapComponent: false }, + }); + + test('should return true **only** when the field value differs from its initial value', async () => { + const { form } = await setup(); + + expect(lastValue()).toBe(false); + + await act(async () => { + form.setInputValue('nameField', 'changed'); + }); + + expect(lastValue()).toBe(true); + + // Put back to the initial value --> isModified should be false + await act(async () => { + form.setInputValue('nameField', 'initialValue'); + }); + expect(lastValue()).toBe(false); + }); +}); diff --git a/src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/use_form_is_modified.ts b/src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/use_form_is_modified.ts new file mode 100644 index 0000000000000..466a0753cf8b9 --- /dev/null +++ b/src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/use_form_is_modified.ts @@ -0,0 +1,49 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ +import { FormHook } from '../types'; +import { useFormContext } from '../form_context'; +import { useFormData } from './use_form_data'; + +interface Options { + form?: FormHook; +} + +/** + * Hook to detect if any of the form fields have been modified by the user. + * If a field is modified and then the value is changed back to the initial value + * the form **won't be marked as modified**. + * This is useful to detect if a form has changed and we need to display a confirm modal + * to the user before he navigates away and loses his changes. + * + * @param options - Optional options object + * @returns flag to indicate if the form has been modified + */ +export const useFormIsModified = ({ form: formFromOptions }: Options = {}): boolean => { + // As hook calls can not be conditional we first try to access the form through context + let form = useFormContext({ throwIfNotFound: false }); + + if (formFromOptions) { + form = formFromOptions; + } + + if (!form) { + throw new Error( + `useFormIsModified() used outside the form context and no form was provided in the options.` + ); + } + + const { getFields } = form; + + // We listen to all the form data change to trigger re-render... + useFormData({ form }); + + // ...and update our derived "isModified" state + const isModified = Object.values(getFields()).some((field) => field.isModified); + + return isModified; +}; diff --git a/src/plugins/es_ui_shared/static/forms/hook_form_lib/index.ts b/src/plugins/es_ui_shared/static/forms/hook_form_lib/index.ts index 72dbea3b14cce..19121bb6753a0 100644 --- a/src/plugins/es_ui_shared/static/forms/hook_form_lib/index.ts +++ b/src/plugins/es_ui_shared/static/forms/hook_form_lib/index.ts @@ -6,9 +6,9 @@ * Side Public License, v 1. */ -// Only export the useForm hook. The "useField" hook is for internal use -// as the consumer of the library must use the component -export { useForm, useFormData } from './hooks'; +// We don't export the "useField" hook as it is for internal use. +// The consumer of the library must use the component to create a field +export { useForm, useFormData, useFormIsModified } from './hooks'; export { getFieldValidityAndErrorMessage } from './helpers'; export * from './form_context'; diff --git a/src/plugins/es_ui_shared/static/forms/hook_form_lib/types.ts b/src/plugins/es_ui_shared/static/forms/hook_form_lib/types.ts index 75c918d4340f2..c16ab2cf7f561 100644 --- a/src/plugins/es_ui_shared/static/forms/hook_form_lib/types.ts +++ b/src/plugins/es_ui_shared/static/forms/hook_form_lib/types.ts @@ -106,6 +106,8 @@ export interface FieldHook { readonly errors: ValidationError[]; readonly isValid: boolean; readonly isPristine: boolean; + readonly isDirty: boolean; + readonly isModified: boolean; readonly isValidating: boolean; readonly isValidated: boolean; readonly isChangingValue: boolean; From 5fea64e15e0807672c16ace5a4823e55705f0462 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Se=CC=81bastien=20Loix?= Date: Tue, 15 Jun 2021 14:57:30 +0100 Subject: [PATCH 16/41] Hide flyout close button and show confirm modal when cancel --- .../public/overlays/flyout/flyout_service.tsx | 11 +- .../delete_field_modal.tsx | 0 .../public/components/confirm_modals/index.ts | 11 ++ .../confirm_modals/modified_field_modal.tsx | 52 +++++++ .../components/field_editor/field_editor.tsx | 25 ++-- .../field_editor_flyout_content.tsx | 127 ++++++++++++------ .../flyout_panels/flyout_panels.tsx | 12 +- .../public/open_delete_modal.tsx | 2 +- .../public/open_editor.tsx | 8 +- .../public/plugin.test.tsx | 2 +- .../public/shared_imports.ts | 1 + .../translations/translations/ja-JP.json | 97 +++++++------ .../translations/translations/zh-CN.json | 99 +++++++------- 13 files changed, 284 insertions(+), 163 deletions(-) rename src/plugins/index_pattern_field_editor/public/components/{ => confirm_modals}/delete_field_modal.tsx (100%) create mode 100644 src/plugins/index_pattern_field_editor/public/components/confirm_modals/index.ts create mode 100644 src/plugins/index_pattern_field_editor/public/components/confirm_modals/modified_field_modal.tsx diff --git a/src/core/public/overlays/flyout/flyout_service.tsx b/src/core/public/overlays/flyout/flyout_service.tsx index 603736f08268f..78089aa45fa5a 100644 --- a/src/core/public/overlays/flyout/flyout_service.tsx +++ b/src/core/public/overlays/flyout/flyout_service.tsx @@ -8,7 +8,7 @@ /* eslint-disable max-classes-per-file */ -import { EuiFlyout, EuiFlyoutSize } from '@elastic/eui'; +import { EuiFlyout, EuiFlyoutProps } from '@elastic/eui'; import React from 'react'; import { render, unmountComponentAtNode } from 'react-dom'; import { Subject } from 'rxjs'; @@ -77,14 +77,7 @@ export interface OverlayFlyoutStart { /** * @public */ -export interface OverlayFlyoutOpenOptions { - className?: string; - closeButtonAriaLabel?: string; - ownFocus?: boolean; - 'data-test-subj'?: string; - size?: EuiFlyoutSize; - maxWidth?: boolean | number | string; -} +export type OverlayFlyoutOpenOptions = Omit; interface StartDeps { i18n: I18nStart; diff --git a/src/plugins/index_pattern_field_editor/public/components/delete_field_modal.tsx b/src/plugins/index_pattern_field_editor/public/components/confirm_modals/delete_field_modal.tsx similarity index 100% rename from src/plugins/index_pattern_field_editor/public/components/delete_field_modal.tsx rename to src/plugins/index_pattern_field_editor/public/components/confirm_modals/delete_field_modal.tsx diff --git a/src/plugins/index_pattern_field_editor/public/components/confirm_modals/index.ts b/src/plugins/index_pattern_field_editor/public/components/confirm_modals/index.ts new file mode 100644 index 0000000000000..f82558904190a --- /dev/null +++ b/src/plugins/index_pattern_field_editor/public/components/confirm_modals/index.ts @@ -0,0 +1,11 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export { DeleteFieldModal } from './delete_field_modal'; + +export { ModifiedFieldModal } from './modified_field_modal'; diff --git a/src/plugins/index_pattern_field_editor/public/components/confirm_modals/modified_field_modal.tsx b/src/plugins/index_pattern_field_editor/public/components/confirm_modals/modified_field_modal.tsx new file mode 100644 index 0000000000000..9b9f01e7a0f6f --- /dev/null +++ b/src/plugins/index_pattern_field_editor/public/components/confirm_modals/modified_field_modal.tsx @@ -0,0 +1,52 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ +import React from 'react'; +import { i18n } from '@kbn/i18n'; +import { EuiConfirmModal } from '@elastic/eui'; + +const i18nTexts = { + title: i18n.translate('indexPatternFieldEditor.cancelField.confirmationModal.title', { + defaultMessage: 'Unsaved changes', + }), + description: i18n.translate('indexPatternFieldEditor.cancelField.confirmationModal.description', { + defaultMessage: `Changes that you've made to your field will be discarded, are you sure you want to continue?`, + }), + confirmButton: i18n.translate( + 'indexPatternFieldEditor.cancelField.confirmationModal.yesButtonLabel', + { + defaultMessage: 'Yes', + } + ), + cancelButton: i18n.translate( + 'indexPatternFieldEditor.cancelField.confirmationModal.cancelButtonLabel', + { + defaultMessage: 'No', + } + ), +}; + +interface Props { + onConfirm: () => void; + onCancel: () => void; +} + +export const ModifiedFieldModal: React.FC = ({ onCancel, onConfirm }) => { + return ( + +

{i18nTexts.description}

+
+ ); +}; diff --git a/src/plugins/index_pattern_field_editor/public/components/field_editor/field_editor.tsx b/src/plugins/index_pattern_field_editor/public/components/field_editor/field_editor.tsx index 1f386ed80887e..4b08d7207969d 100644 --- a/src/plugins/index_pattern_field_editor/public/components/field_editor/field_editor.tsx +++ b/src/plugins/index_pattern_field_editor/public/components/field_editor/field_editor.tsx @@ -23,6 +23,7 @@ import { Form, useForm, useFormData, + useFormIsModified, FormHook, UseField, TextField, @@ -67,6 +68,8 @@ export interface Props { field?: Field; /** Handler to receive state changes updates */ onChange?: (state: FieldEditorFormState) => void; + /** Handler to receive update on the form "dirty" state */ + onFormModifiedChange?: (isDirty: boolean) => void; syntaxError: ScriptSyntaxError; } @@ -147,7 +150,7 @@ const formSerializer = (field: FieldFormInternal): Field => { }; }; -const FieldEditorComponent = ({ field, onChange, syntaxError }: Props) => { +const FieldEditorComponent = ({ field, onChange, onFormModifiedChange, syntaxError }: Props) => { const { links, namesNotAllowed, @@ -164,19 +167,19 @@ const FieldEditorComponent = ({ field, onChange, syntaxError }: Props) => { deserializer: formDeserializer, serializer: formSerializer, }); - const { submit, isValid: isFormValid, isSubmitted } = form; + const { submit, isValid: isFormValid, isSubmitted, getFields } = form; const { clear: clearSyntaxError } = syntaxError; - const [{ type }] = useFormData({ form }); - const nameFieldConfig = getNameFieldConfig(namesNotAllowed, field); const i18nTexts = geti18nTexts(); const [formData] = useFormData({ form }); + const isFormModified = useFormIsModified({ form }); + const { name: updatedName, type: updatedType, script: updatedScript } = formData; - const nameHasChanged = Boolean(field?.name) && field?.name !== updatedName; - const typeHasChanged = - Boolean(field?.type) && field?.type !== (updatedType && updatedType[0].value); + const nameHasChanged = getFields().name?.isModified ?? false; + const typeHasChanged = getFields().type?.isModified ?? false; + const isValueVisible = get(formData, '__meta__.isValueVisible'); const isFormatVisible = get(formData, '__meta__.isFormatVisible'); @@ -190,7 +193,7 @@ const FieldEditorComponent = ({ field, onChange, syntaxError }: Props) => { // Whenever the field "type" changes we clear any possible painless syntax // error as it is possibly stale. clearSyntaxError(); - }, [type, clearSyntaxError]); + }, [updatedType, clearSyntaxError]); useEffect(() => { updatePreviewParams({ @@ -211,6 +214,12 @@ const FieldEditorComponent = ({ field, onChange, syntaxError }: Props) => { } }, [isValueVisible, isFormatVisible, setIsPanelVisible]); + useEffect(() => { + if (onFormModifiedChange) { + onFormModifiedChange(isFormModified); + } + }, [isFormModified, onFormModifiedChange]); + return (
diff --git a/src/plugins/index_pattern_field_editor/public/components/field_editor_flyout_content.tsx b/src/plugins/index_pattern_field_editor/public/components/field_editor_flyout_content.tsx index 0b650e4b0560c..c217b530e40df 100644 --- a/src/plugins/index_pattern_field_editor/public/components/field_editor_flyout_content.tsx +++ b/src/plugins/index_pattern_field_editor/public/components/field_editor_flyout_content.tsx @@ -30,11 +30,12 @@ import { FlyoutPanels } from './flyout_panels'; import { useFieldEditorContext } from './field_editor_context'; import { FieldEditor, FieldEditorFormState } from './field_editor/field_editor'; import { FieldPreview, useFieldPreviewContext } from './preview'; +import { ModifiedFieldModal } from './confirm_modals'; const geti18nTexts = (field?: Field) => { return { - closeButtonLabel: i18n.translate('indexPatternFieldEditor.editor.flyoutCloseButtonLabel', { - defaultMessage: 'Close', + cancelButtonLabel: i18n.translate('indexPatternFieldEditor.editor.flyoutCancelButtonLabel', { + defaultMessage: 'Cancel', }), saveButtonLabel: i18n.translate('indexPatternFieldEditor.editor.flyoutSaveButtonLabel', { defaultMessage: 'Save', @@ -79,6 +80,11 @@ const geti18nTexts = (field?: Field) => { }; }; +const defaultModalVisibility = { + confirmChangeNameOrType: false, + confirmUnsavedChanges: false, +}; + export interface Props { /** * Handler for the "save" footer button @@ -122,8 +128,9 @@ const FieldEditorFlyoutContentComponent = ({ ); const [isValidating, setIsValidating] = useState(false); - const [isModalVisible, setIsModalVisible] = useState(false); + const [modalVisibility, setModalVisibility] = useState(defaultModalVisibility); const [confirmContent, setConfirmContent] = useState(''); + const [isFormModified, setIsFormModified] = useState(false); const { submit, isValid: isFormValid, isSubmitted } = formState; const hasErrors = isFormValid === false || painlessSyntaxError !== null; @@ -161,51 +168,86 @@ const FieldEditorFlyoutContentComponent = ({ } if (isEditingExistingField && (nameChange || typeChange)) { - setIsModalVisible(true); + setModalVisibility({ + ...defaultModalVisibility, + confirmChangeNameOrType: true, + }); } else { onSave(data); } } }, [onSave, submit, runtimeFieldValidator, field, isEditingExistingField]); - const modal = isModalVisible ? ( - { - setIsModalVisible(false); - setConfirmContent(''); - }} - onConfirm={async () => { - const { data } = await submit(); - onSave(data); - }} - > - - - - setConfirmContent(e.target.value)} - data-test-subj="saveModalConfirmText" + const onClickCancel = useCallback(() => { + if (isFormModified) { + setModalVisibility({ + ...defaultModalVisibility, + confirmUnsavedChanges: true, + }); + return; + } + + onCancel(); + }, [onCancel, isFormModified]); + + const renderModal = () => { + if (modalVisibility.confirmChangeNameOrType) { + return ( + { + setModalVisibility(defaultModalVisibility); + setConfirmContent(''); + }} + onConfirm={async () => { + const { data } = await submit(); + onSave(data); + }} + > + + + + setConfirmContent(e.target.value)} + data-test-subj="saveModalConfirmText" + /> + + + ); + } + + if (modalVisibility.confirmUnsavedChanges) { + return ( + { + setModalVisibility(defaultModalVisibility); + onCancel(); + }} + onCancel={() => { + setModalVisibility(defaultModalVisibility); + }} /> - - - ) : null; + ); + } + + return null; + }; return ( <> {/* Editor panel */} - + @@ -239,7 +281,12 @@ const FieldEditorFlyoutContentComponent = ({ - + @@ -260,10 +307,10 @@ const FieldEditorFlyoutContentComponent = ({ - {i18nTexts.closeButtonLabel} + {i18nTexts.cancelButtonLabel} @@ -292,7 +339,7 @@ const FieldEditorFlyoutContentComponent = ({ )} - {modal} + {renderModal()} ); }; diff --git a/src/plugins/index_pattern_field_editor/public/components/flyout_panels/flyout_panels.tsx b/src/plugins/index_pattern_field_editor/public/components/flyout_panels/flyout_panels.tsx index 61ef1adf899d2..c029476ebcc48 100644 --- a/src/plugins/index_pattern_field_editor/public/components/flyout_panels/flyout_panels.tsx +++ b/src/plugins/index_pattern_field_editor/public/components/flyout_panels/flyout_panels.tsx @@ -39,6 +39,9 @@ const flyoutPanelsContext = createContext({ }, }); +const limitWidthToWindow = (width: number, { innerWidth }: Window): number => + Math.min(width, innerWidth * 0.8); + export interface Props { /** * The total max width with all the panels in the DOM @@ -111,16 +114,17 @@ export const Panels: React.FC = ({ if (fixedPanelWidths) { const totalWidth = Object.values(panels).reduce((acc, { width = 0 }) => acc + width, 0); currentWidth = Math.min(maxWidth, totalWidth); - flyoutDOMelement.style.width = `${currentWidth}px`; - flyoutDOMelement.style.minWidth = `${currentWidth}px`; - flyoutDOMelement.style.maxWidth = `${currentWidth}px`; + // As EUI declares both min-width and max-width on the .euiFlyout CSS class + // we need to override both values + flyoutDOMelement.style.minWidth = `${limitWidthToWindow(currentWidth, window)}px`; + flyoutDOMelement.style.maxWidth = `${limitWidthToWindow(currentWidth, window)}px`; } else { const totalPercentWidth = Math.min( 100, Object.values(panels).reduce((acc, { width = 0 }) => acc + width, 0) ); currentWidth = (maxWidth * totalPercentWidth) / 100; - flyoutDOMelement.style.maxWidth = `${currentWidth}px`; + flyoutDOMelement.style.maxWidth = `${limitWidthToWindow(currentWidth, window)}px`; } }, [panels, maxWidth, fixedPanelWidths, flyoutClassName, flyoutDOMelement]); diff --git a/src/plugins/index_pattern_field_editor/public/open_delete_modal.tsx b/src/plugins/index_pattern_field_editor/public/open_delete_modal.tsx index 27aa1d0313a7f..72dbb76863353 100644 --- a/src/plugins/index_pattern_field_editor/public/open_delete_modal.tsx +++ b/src/plugins/index_pattern_field_editor/public/open_delete_modal.tsx @@ -18,7 +18,7 @@ import { import { CloseEditor } from './types'; -import { DeleteFieldModal } from './components/delete_field_modal'; +import { DeleteFieldModal } from './components/confirm_modals/delete_field_modal'; import { removeFields } from './lib/remove_fields'; export interface OpenFieldDeleteModalOptions { diff --git a/src/plugins/index_pattern_field_editor/public/open_editor.tsx b/src/plugins/index_pattern_field_editor/public/open_editor.tsx index 44194ba8a3806..31f03418f7fa3 100644 --- a/src/plugins/index_pattern_field_editor/public/open_editor.tsx +++ b/src/plugins/index_pattern_field_editor/public/open_editor.tsx @@ -118,7 +118,13 @@ export const getFieldEditorOpener = ({ /> ), - { className: euiFlyoutClassname, maxWidth: 708, size: 'l', ownFocus: true } + { + className: euiFlyoutClassname, + maxWidth: 708, + size: 'l', + ownFocus: true, + hideCloseButton: true, + } ); return closeEditor; diff --git a/src/plugins/index_pattern_field_editor/public/plugin.test.tsx b/src/plugins/index_pattern_field_editor/public/plugin.test.tsx index b0aa389973660..75bb1322d305e 100644 --- a/src/plugins/index_pattern_field_editor/public/plugin.test.tsx +++ b/src/plugins/index_pattern_field_editor/public/plugin.test.tsx @@ -24,7 +24,7 @@ import { usageCollectionPluginMock } from '../../usage_collection/public/mocks'; import { FieldEditorLoader } from './components/field_editor_loader'; import { IndexPatternFieldEditorPlugin } from './plugin'; -import { DeleteFieldModal } from './components/delete_field_modal'; +import { DeleteFieldModal } from './components/confirm_modals/delete_field_modal'; import { IndexPattern } from './shared_imports'; const noop = () => {}; diff --git a/src/plugins/index_pattern_field_editor/public/shared_imports.ts b/src/plugins/index_pattern_field_editor/public/shared_imports.ts index e26fed977aec7..3ef26dd67b8d2 100644 --- a/src/plugins/index_pattern_field_editor/public/shared_imports.ts +++ b/src/plugins/index_pattern_field_editor/public/shared_imports.ts @@ -23,6 +23,7 @@ export { useForm, useFormData, useFormContext, + useFormIsModified, Form, FormSchema, UseField, diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 4c927d5094ca4..990b85d05dfc3 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -2803,7 +2803,6 @@ "indexPatternFieldEditor.duration.outputFormatLabel": "アウトプット形式", "indexPatternFieldEditor.duration.showSuffixLabel": "接尾辞を表示", "indexPatternFieldEditor.durationErrorMessage": "小数部分の桁数は0から20までの間で指定する必要があります", - "indexPatternFieldEditor.editor.flyoutCloseButtonLabel": "閉じる", "indexPatternFieldEditor.editor.flyoutDefaultTitle": "フィールドを作成", "indexPatternFieldEditor.editor.flyoutEditFieldSubtitle": "インデックスパターン:{patternName}", "indexPatternFieldEditor.editor.flyoutEditFieldTitle": "「{fieldName}」フィールドの編集", @@ -4923,17 +4922,6 @@ "visTypeVislib.heatmap.metricTitle": "値", "visTypeVislib.heatmap.segmentTitle": "X 軸", "visTypeVislib.heatmap.splitTitle": "チャートを分割", - "visTypePie.pie.metricTitle": "スライスサイズ", - "visTypePie.pie.pieDescription": "全体に対する比率でデータを比較します。", - "visTypePie.pie.pieTitle": "円", - "visTypePie.pie.segmentTitle": "スライスの分割", - "visTypePie.pie.splitTitle": "チャートを分割", - "visTypePie.editors.pie.donutLabel": "ドーナッツ", - "visTypePie.editors.pie.labelsSettingsTitle": "ラベル設定", - "visTypePie.editors.pie.pieSettingsTitle": "パイ設定", - "visTypePie.editors.pie.showLabelsLabel": "ラベルを表示", - "visTypePie.editors.pie.showTopLevelOnlyLabel": "トップレベルのみ表示", - "visTypePie.editors.pie.showValuesLabel": "値を表示", "visTypeVislib.vislib.errors.noResultsFoundTitle": "結果が見つかりませんでした", "visTypeVislib.vislib.heatmap.maxBucketsText": "定義された数列が多すぎます ({nr}) 。構成されている最大値は {max} です。", "visTypeVislib.vislib.legend.filterForValueButtonAriaLabel": "値 {legendDataLabel} でフィルタリング", @@ -4945,8 +4933,56 @@ "visTypeVislib.vislib.legend.toggleOptionsButtonAriaLabel": "{legendDataLabel}、トグルオプション", "visTypeVislib.vislib.tooltip.fieldLabel": "フィールド", "visTypeVislib.vislib.tooltip.valueLabel": "値", + "visTypePie.pie.metricTitle": "スライスサイズ", + "visTypePie.pie.pieDescription": "全体に対する比率でデータを比較します。", + "visTypePie.pie.pieTitle": "円", + "visTypePie.pie.segmentTitle": "スライスの分割", + "visTypePie.pie.splitTitle": "チャートを分割", + "visTypePie.editors.pie.donutLabel": "ドーナッツ", + "visTypePie.editors.pie.labelsSettingsTitle": "ラベル設定", + "visTypePie.editors.pie.pieSettingsTitle": "パイ設定", + "visTypePie.editors.pie.showLabelsLabel": "ラベルを表示", + "visTypePie.editors.pie.showTopLevelOnlyLabel": "トップレベルのみ表示", + "visTypePie.editors.pie.showValuesLabel": "値を表示", "visualizations.advancedSettings.visualization.legacyChartsLibrary.description": "Visualizeでエリア、折れ線、棒グラフのレガシーグラフライブラリを有効にします。", "visualizations.advancedSettings.visualization.legacyChartsLibrary.name": "レガシーグラフライブラリ", + "visualizations.advancedSettings.visualizeEnableLabsText": "ユーザーが実験的なビジュアライゼーションを作成、表示、編集できるようになります。無効の場合、\n ユーザーは本番準備が整ったビジュアライゼーションのみを利用できます。", + "visualizations.advancedSettings.visualizeEnableLabsTitle": "実験的なビジュアライゼーションを有効にする", + "visualizations.disabledLabVisualizationLink": "ドキュメンテーションを表示", + "visualizations.disabledLabVisualizationMessage": "ラボビジュアライゼーションを表示するには、高度な設定でラボモードをオンにしてください。", + "visualizations.disabledLabVisualizationTitle": "{title} はラボビジュアライゼーションです。", + "visualizations.displayName": "ビジュアライゼーション", + "visualizations.embeddable.placeholderTitle": "プレースホルダータイトル", + "visualizations.function.range.from.help": "範囲の開始", + "visualizations.function.range.help": "範囲オブジェクトを生成します", + "visualizations.function.range.to.help": "範囲の終了", + "visualizations.function.visDimension.accessor.help": "使用するデータセット内の列 (列インデックスまたは列名) ", + "visualizations.function.visDimension.error.accessor": "入力された列名は無効です。", + "visualizations.function.visDimension.format.help": "フォーマット", + "visualizations.function.visDimension.formatParams.help": "フォーマットパラメーター", + "visualizations.function.visDimension.help": "visConfig ディメンションオブジェクトを生成します", + "visualizations.initializeWithoutIndexPatternErrorMessage": "インデックスパターンなしで集約を初期化しようとしています", + "visualizations.newVisWizard.aggBasedGroupDescription": "クラシック Visualize ライブラリを使用して、アグリゲーションに基づいてグラフを作成します。", + "visualizations.newVisWizard.aggBasedGroupTitle": "アグリゲーションに基づく", + "visualizations.newVisWizard.chooseSourceTitle": "ソースの選択", + "visualizations.newVisWizard.experimentalTitle": "実験的", + "visualizations.newVisWizard.experimentalTooltip": "このビジュアライゼーションは今後のリリースで変更または削除される可能性があり、SLA のサポート対象になりません。", + "visualizations.newVisWizard.exploreOptionLinkText": "探索オプション", + "visualizations.newVisWizard.filterVisTypeAriaLabel": "ビジュアライゼーションのタイプでフィルタリング", + "visualizations.newVisWizard.goBackLink": "別のビジュアライゼーションを選択", + "visualizations.newVisWizard.helpTextAriaLabel": "タイプを選択してビジュアライゼーションの作成を始めましょう。ESC を押してこのモーダルを閉じます。Tab キーを押して次に進みます。", + "visualizations.newVisWizard.learnMoreText": "詳細について", + "visualizations.newVisWizard.newVisTypeTitle": "新規 {visTypeName}", + "visualizations.newVisWizard.readDocumentationLink": "ドキュメンテーションを表示", + "visualizations.newVisWizard.searchSelection.notFoundLabel": "一致インデックスまたは保存した検索が見つかりません。", + "visualizations.newVisWizard.searchSelection.savedObjectType.indexPattern": "インデックスパターン", + "visualizations.newVisWizard.searchSelection.savedObjectType.search": "保存検索", + "visualizations.newVisWizard.title": "新規ビジュアライゼーション", + "visualizations.newVisWizard.toolsGroupTitle": "ツール", + "visualizations.noResultsFoundTitle": "結果が見つかりませんでした", + "visualizations.savedObjectName": "ビジュアライゼーション", + "visualizations.savingVisualizationFailed.errorMsg": "ビジュアライゼーションの保存が失敗しました", + "visualizations.visualizationTypeInvalidMessage": "無効なビジュアライゼーションタイプ \"{visType}\"", "visTypeXy.aggResponse.allDocsTitle": "すべてのドキュメント", "visTypeXy.area.areaDescription": "軸と線の間のデータを強調します。", "visTypeXy.area.areaTitle": "エリア", @@ -5068,43 +5104,6 @@ "visTypeXy.thresholdLine.style.dashedText": "鎖線", "visTypeXy.thresholdLine.style.dotdashedText": "点線", "visTypeXy.thresholdLine.style.fullText": "完全", - "visualizations.advancedSettings.visualizeEnableLabsText": "ユーザーが実験的なビジュアライゼーションを作成、表示、編集できるようになります。無効の場合、\n ユーザーは本番準備が整ったビジュアライゼーションのみを利用できます。", - "visualizations.advancedSettings.visualizeEnableLabsTitle": "実験的なビジュアライゼーションを有効にする", - "visualizations.disabledLabVisualizationLink": "ドキュメンテーションを表示", - "visualizations.disabledLabVisualizationMessage": "ラボビジュアライゼーションを表示するには、高度な設定でラボモードをオンにしてください。", - "visualizations.disabledLabVisualizationTitle": "{title} はラボビジュアライゼーションです。", - "visualizations.displayName": "ビジュアライゼーション", - "visualizations.embeddable.placeholderTitle": "プレースホルダータイトル", - "visualizations.function.range.from.help": "範囲の開始", - "visualizations.function.range.help": "範囲オブジェクトを生成します", - "visualizations.function.range.to.help": "範囲の終了", - "visualizations.function.visDimension.accessor.help": "使用するデータセット内の列 (列インデックスまたは列名) ", - "visualizations.function.visDimension.error.accessor": "入力された列名は無効です。", - "visualizations.function.visDimension.format.help": "フォーマット", - "visualizations.function.visDimension.formatParams.help": "フォーマットパラメーター", - "visualizations.function.visDimension.help": "visConfig ディメンションオブジェクトを生成します", - "visualizations.initializeWithoutIndexPatternErrorMessage": "インデックスパターンなしで集約を初期化しようとしています", - "visualizations.newVisWizard.aggBasedGroupDescription": "クラシック Visualize ライブラリを使用して、アグリゲーションに基づいてグラフを作成します。", - "visualizations.newVisWizard.aggBasedGroupTitle": "アグリゲーションに基づく", - "visualizations.newVisWizard.chooseSourceTitle": "ソースの選択", - "visualizations.newVisWizard.experimentalTitle": "実験的", - "visualizations.newVisWizard.experimentalTooltip": "このビジュアライゼーションは今後のリリースで変更または削除される可能性があり、SLA のサポート対象になりません。", - "visualizations.newVisWizard.exploreOptionLinkText": "探索オプション", - "visualizations.newVisWizard.filterVisTypeAriaLabel": "ビジュアライゼーションのタイプでフィルタリング", - "visualizations.newVisWizard.goBackLink": "別のビジュアライゼーションを選択", - "visualizations.newVisWizard.helpTextAriaLabel": "タイプを選択してビジュアライゼーションの作成を始めましょう。ESC を押してこのモーダルを閉じます。Tab キーを押して次に進みます。", - "visualizations.newVisWizard.learnMoreText": "詳細について", - "visualizations.newVisWizard.newVisTypeTitle": "新規 {visTypeName}", - "visualizations.newVisWizard.readDocumentationLink": "ドキュメンテーションを表示", - "visualizations.newVisWizard.searchSelection.notFoundLabel": "一致インデックスまたは保存した検索が見つかりません。", - "visualizations.newVisWizard.searchSelection.savedObjectType.indexPattern": "インデックスパターン", - "visualizations.newVisWizard.searchSelection.savedObjectType.search": "保存検索", - "visualizations.newVisWizard.title": "新規ビジュアライゼーション", - "visualizations.newVisWizard.toolsGroupTitle": "ツール", - "visualizations.noResultsFoundTitle": "結果が見つかりませんでした", - "visualizations.savedObjectName": "ビジュアライゼーション", - "visualizations.savingVisualizationFailed.errorMsg": "ビジュアライゼーションの保存が失敗しました", - "visualizations.visualizationTypeInvalidMessage": "無効なビジュアライゼーションタイプ \"{visType}\"", "visualize.badge.readOnly.text": "読み取り専用", "visualize.badge.readOnly.tooltip": "ビジュアライゼーションをライブラリに保存できません", "visualize.byValue_pageHeading": "{originatingApp}アプリに埋め込まれた{chartType}タイプのビジュアライゼーション", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 57a1b6a8751fd..f099dd6b96668 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -2817,7 +2817,6 @@ "indexPatternFieldEditor.duration.outputFormatLabel": "输出格式", "indexPatternFieldEditor.duration.showSuffixLabel": "显示后缀", "indexPatternFieldEditor.durationErrorMessage": "小数位数必须介于 0 和 20 之间", - "indexPatternFieldEditor.editor.flyoutCloseButtonLabel": "关闭", "indexPatternFieldEditor.editor.flyoutDefaultTitle": "创建字段", "indexPatternFieldEditor.editor.flyoutEditFieldSubtitle": "索引模式:{patternName}", "indexPatternFieldEditor.editor.flyoutEditFieldTitle": "编辑字段“{fieldName}”", @@ -4950,17 +4949,6 @@ "visTypeVislib.heatmap.metricTitle": "值", "visTypeVislib.heatmap.segmentTitle": "X 轴", "visTypeVislib.heatmap.splitTitle": "拆分图表", - "visTypePie.pie.metricTitle": "切片大小", - "visTypePie.pie.pieDescription": "以整体的比例比较数据。", - "visTypePie.pie.pieTitle": "饼图", - "visTypePie.pie.segmentTitle": "拆分切片", - "visTypePie.pie.splitTitle": "拆分图表", - "visTypePie.editors.pie.donutLabel": "圆环图", - "visTypePie.editors.pie.labelsSettingsTitle": "标签设置", - "visTypePie.editors.pie.pieSettingsTitle": "饼图设置", - "visTypePie.editors.pie.showLabelsLabel": "显示标签", - "visTypePie.editors.pie.showTopLevelOnlyLabel": "仅显示顶级", - "visTypePie.editors.pie.showValuesLabel": "显示值", "visTypeVislib.vislib.errors.noResultsFoundTitle": "找不到结果", "visTypeVislib.vislib.heatmap.maxBucketsText": "定义了过多的序列 ({nr})。配置的最大值为 {max}。", "visTypeVislib.vislib.legend.filterForValueButtonAriaLabel": "筛留值 {legendDataLabel}", @@ -4972,8 +4960,57 @@ "visTypeVislib.vislib.legend.toggleOptionsButtonAriaLabel": "{legendDataLabel}, 切换选项", "visTypeVislib.vislib.tooltip.fieldLabel": "字段", "visTypeVislib.vislib.tooltip.valueLabel": "值", + "visTypePie.pie.metricTitle": "切片大小", + "visTypePie.pie.pieDescription": "以整体的比例比较数据。", + "visTypePie.pie.pieTitle": "饼图", + "visTypePie.pie.segmentTitle": "拆分切片", + "visTypePie.pie.splitTitle": "拆分图表", + "visTypePie.editors.pie.donutLabel": "圆环图", + "visTypePie.editors.pie.labelsSettingsTitle": "标签设置", + "visTypePie.editors.pie.pieSettingsTitle": "饼图设置", + "visTypePie.editors.pie.showLabelsLabel": "显示标签", + "visTypePie.editors.pie.showTopLevelOnlyLabel": "仅显示顶级", + "visTypePie.editors.pie.showValuesLabel": "显示值", "visualizations.advancedSettings.visualization.legacyChartsLibrary.description": "在 Visualize 中启用面积图、折线图和条形图的旧版图表库。", "visualizations.advancedSettings.visualization.legacyChartsLibrary.name": "旧版图表库", + "visualizations.advancedSettings.visualizeEnableLabsText": "允许用户创建、查看和编辑实验性可视化。如果禁用,\n 仅被视为生产就绪的可视化可供用户使用。", + "visualizations.advancedSettings.visualizeEnableLabsTitle": "启用实验性可视化", + "visualizations.disabledLabVisualizationLink": "阅读文档", + "visualizations.disabledLabVisualizationMessage": "请在高级设置中打开实验模式,以查看实验性可视化。", + "visualizations.disabledLabVisualizationTitle": "{title} 为实验室可视化。", + "visualizations.displayName": "可视化", + "visualizations.embeddable.placeholderTitle": "占位符标题", + "visualizations.function.range.from.help": "范围起始", + "visualizations.function.range.help": "生成范围对象", + "visualizations.function.range.to.help": "范围结束", + "visualizations.function.visDimension.accessor.help": "要使用的数据集列 (列索引或列名称) ", + "visualizations.function.visDimension.error.accessor": "提供的列名称无效", + "visualizations.function.visDimension.format.help": "格式", + "visualizations.function.visDimension.formatParams.help": "格式参数", + "visualizations.function.visDimension.help": "生成 visConfig 维度对象", + "visualizations.initializeWithoutIndexPatternErrorMessage": "正在尝试在不使用索引模式的情况下初始化聚合", + "visualizations.newVisWizard.aggBasedGroupDescription": "使用我们的经典可视化库,基于聚合创建图表。", + "visualizations.newVisWizard.aggBasedGroupTitle": "基于聚合", + "visualizations.newVisWizard.chooseSourceTitle": "选择源", + "visualizations.newVisWizard.experimentalTitle": "实验性", + "visualizations.newVisWizard.experimentalTooltip": "未来版本可能会更改或删除此可视化,其不受支持 SLA 的约束。", + "visualizations.newVisWizard.exploreOptionLinkText": "浏览选项", + "visualizations.newVisWizard.filterVisTypeAriaLabel": "筛留可视化类型", + "visualizations.newVisWizard.goBackLink": "选择不同的可视化", + "visualizations.newVisWizard.helpTextAriaLabel": "通过为该可视化选择类型,开始创建您的可视化。按 Esc 键关闭此模式。按 Tab 键继续。", + "visualizations.newVisWizard.learnMoreText": "希望了解详情?", + "visualizations.newVisWizard.newVisTypeTitle": "新建{visTypeName}", + "visualizations.newVisWizard.readDocumentationLink": "阅读文档", + "visualizations.newVisWizard.resultsFound": "{resultCount, plural, other {类型}}已找到", + "visualizations.newVisWizard.searchSelection.notFoundLabel": "未找到匹配的索引或已保存搜索。", + "visualizations.newVisWizard.searchSelection.savedObjectType.indexPattern": "索引模式", + "visualizations.newVisWizard.searchSelection.savedObjectType.search": "已保存搜索", + "visualizations.newVisWizard.title": "新建可视化", + "visualizations.newVisWizard.toolsGroupTitle": "工具", + "visualizations.noResultsFoundTitle": "找不到结果", + "visualizations.savedObjectName": "可视化", + "visualizations.savingVisualizationFailed.errorMsg": "保存可视化失败", + "visualizations.visualizationTypeInvalidMessage": "无效的可视化类型“{visType}”", "visTypeXy.aggResponse.allDocsTitle": "所有文档", "visTypeXy.area.areaDescription": "突出轴与线之间的数据。", "visTypeXy.area.areaTitle": "面积图", @@ -5095,44 +5132,6 @@ "visTypeXy.thresholdLine.style.dashedText": "虚线", "visTypeXy.thresholdLine.style.dotdashedText": "点虚线", "visTypeXy.thresholdLine.style.fullText": "实线", - "visualizations.advancedSettings.visualizeEnableLabsText": "允许用户创建、查看和编辑实验性可视化。如果禁用,\n 仅被视为生产就绪的可视化可供用户使用。", - "visualizations.advancedSettings.visualizeEnableLabsTitle": "启用实验性可视化", - "visualizations.disabledLabVisualizationLink": "阅读文档", - "visualizations.disabledLabVisualizationMessage": "请在高级设置中打开实验模式,以查看实验性可视化。", - "visualizations.disabledLabVisualizationTitle": "{title} 为实验室可视化。", - "visualizations.displayName": "可视化", - "visualizations.embeddable.placeholderTitle": "占位符标题", - "visualizations.function.range.from.help": "范围起始", - "visualizations.function.range.help": "生成范围对象", - "visualizations.function.range.to.help": "范围结束", - "visualizations.function.visDimension.accessor.help": "要使用的数据集列 (列索引或列名称) ", - "visualizations.function.visDimension.error.accessor": "提供的列名称无效", - "visualizations.function.visDimension.format.help": "格式", - "visualizations.function.visDimension.formatParams.help": "格式参数", - "visualizations.function.visDimension.help": "生成 visConfig 维度对象", - "visualizations.initializeWithoutIndexPatternErrorMessage": "正在尝试在不使用索引模式的情况下初始化聚合", - "visualizations.newVisWizard.aggBasedGroupDescription": "使用我们的经典可视化库,基于聚合创建图表。", - "visualizations.newVisWizard.aggBasedGroupTitle": "基于聚合", - "visualizations.newVisWizard.chooseSourceTitle": "选择源", - "visualizations.newVisWizard.experimentalTitle": "实验性", - "visualizations.newVisWizard.experimentalTooltip": "未来版本可能会更改或删除此可视化,其不受支持 SLA 的约束。", - "visualizations.newVisWizard.exploreOptionLinkText": "浏览选项", - "visualizations.newVisWizard.filterVisTypeAriaLabel": "筛留可视化类型", - "visualizations.newVisWizard.goBackLink": "选择不同的可视化", - "visualizations.newVisWizard.helpTextAriaLabel": "通过为该可视化选择类型,开始创建您的可视化。按 Esc 键关闭此模式。按 Tab 键继续。", - "visualizations.newVisWizard.learnMoreText": "希望了解详情?", - "visualizations.newVisWizard.newVisTypeTitle": "新建{visTypeName}", - "visualizations.newVisWizard.readDocumentationLink": "阅读文档", - "visualizations.newVisWizard.resultsFound": "{resultCount, plural, other {类型}}已找到", - "visualizations.newVisWizard.searchSelection.notFoundLabel": "未找到匹配的索引或已保存搜索。", - "visualizations.newVisWizard.searchSelection.savedObjectType.indexPattern": "索引模式", - "visualizations.newVisWizard.searchSelection.savedObjectType.search": "已保存搜索", - "visualizations.newVisWizard.title": "新建可视化", - "visualizations.newVisWizard.toolsGroupTitle": "工具", - "visualizations.noResultsFoundTitle": "找不到结果", - "visualizations.savedObjectName": "可视化", - "visualizations.savingVisualizationFailed.errorMsg": "保存可视化失败", - "visualizations.visualizationTypeInvalidMessage": "无效的可视化类型“{visType}”", "visualize.badge.readOnly.text": "只读", "visualize.badge.readOnly.tooltip": "无法将可视化保存到库", "visualize.byValue_pageHeading": "已嵌入到 {originatingApp} 应用中的 {chartType} 类型可视化", From 64de3e8122e600d0b9fca722b2f7f4a72496eb3c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Se=CC=81bastien=20Loix?= Date: Tue, 15 Jun 2021 15:02:02 +0100 Subject: [PATCH 17/41] Reduce list item height + bolding field being previewed --- .../public/components/preview/field_list/field_list.scss | 5 +++-- .../public/components/preview/field_list/field_list.tsx | 4 ++-- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/src/plugins/index_pattern_field_editor/public/components/preview/field_list/field_list.scss b/src/plugins/index_pattern_field_editor/public/components/preview/field_list/field_list.scss index ccba5945f7ee7..19921deec3460 100644 --- a/src/plugins/index_pattern_field_editor/public/components/preview/field_list/field_list.scss +++ b/src/plugins/index_pattern_field_editor/public/components/preview/field_list/field_list.scss @@ -4,8 +4,8 @@ [3] We need the tooltip to be 100% to display the text ellipsis of the field value */ -$previewFieldItemHeight: 64px; /* [1] */ -$previewShowMoreHeight: 48px; /* [2] */ +$previewFieldItemHeight: 40px; /* [1] */ +$previewShowMoreHeight: 40px; /* [2] */ .indexPatternFieldEditor__previewFieldList { position: relative; @@ -19,6 +19,7 @@ $previewShowMoreHeight: 48px; /* [2] */ &--highlighted { $backgroundColor: tintOrShade($euiColorWarning, 90%, 70%); background: $backgroundColor; + font-weight: 600; } &__key, &__value { diff --git a/src/plugins/index_pattern_field_editor/public/components/preview/field_list/field_list.tsx b/src/plugins/index_pattern_field_editor/public/components/preview/field_list/field_list.tsx index df0ccbfb66335..8fd073445ec46 100644 --- a/src/plugins/index_pattern_field_editor/public/components/preview/field_list/field_list.tsx +++ b/src/plugins/index_pattern_field_editor/public/components/preview/field_list/field_list.tsx @@ -17,8 +17,8 @@ import { PreviewListItem } from './field_list_item'; import './field_list.scss'; -const ITEM_HEIGHT = 64; -const SHOW_MORE_HEIGHT = 48; +const ITEM_HEIGHT = 40; +const SHOW_MORE_HEIGHT = 40; const INITIAL_MAX_NUMBER_OF_FIELDS = 7; export interface Field { From 3e318528d0d215b0f32d523b0d5494b42736c6a0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Se=CC=81bastien=20Loix?= Date: Tue, 15 Jun 2021 15:46:16 +0100 Subject: [PATCH 18/41] Show _source value when no script is provided --- .../components/field_editor/field_editor.tsx | 5 ++- .../components/preview/field_preview.tsx | 6 +-- .../preview/field_preview_context.tsx | 37 +++++++++++++------ 3 files changed, 31 insertions(+), 17 deletions(-) diff --git a/src/plugins/index_pattern_field_editor/public/components/field_editor/field_editor.tsx b/src/plugins/index_pattern_field_editor/public/components/field_editor/field_editor.tsx index 4b08d7207969d..3917248bcc403 100644 --- a/src/plugins/index_pattern_field_editor/public/components/field_editor/field_editor.tsx +++ b/src/plugins/index_pattern_field_editor/public/components/field_editor/field_editor.tsx @@ -177,8 +177,9 @@ const FieldEditorComponent = ({ field, onChange, onFormModifiedChange, syntaxErr const isFormModified = useFormIsModified({ form }); const { name: updatedName, type: updatedType, script: updatedScript } = formData; - const nameHasChanged = getFields().name?.isModified ?? false; - const typeHasChanged = getFields().type?.isModified ?? false; + const { name: nameField, type: typeField } = getFields(); + const nameHasChanged = (Boolean(field?.name) && nameField?.isModified) ?? false; + const typeHasChanged = (Boolean(field?.type) && typeField?.isModified) ?? false; const isValueVisible = get(formData, '__meta__.isValueVisible'); const isFormatVisible = get(formData, '__meta__.isFormatVisible'); diff --git a/src/plugins/index_pattern_field_editor/public/components/preview/field_preview.tsx b/src/plugins/index_pattern_field_editor/public/components/preview/field_preview.tsx index b9bbfeebf8976..c55b8c5d3dd97 100644 --- a/src/plugins/index_pattern_field_editor/public/components/preview/field_preview.tsx +++ b/src/plugins/index_pattern_field_editor/public/components/preview/field_preview.tsx @@ -33,10 +33,10 @@ export const FieldPreview = () => { // 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 const isEmptyPromptVisible = - previewCount === 0 - ? true - : error !== null || fields.length > 0 + error !== null || fields.length > 0 ? false + : previewCount === 0 + ? true : name === null || (script === null && format === null); const onFieldListResize = useCallback(({ height }: { height: number }) => { diff --git a/src/plugins/index_pattern_field_editor/public/components/preview/field_preview_context.tsx b/src/plugins/index_pattern_field_editor/public/components/preview/field_preview_context.tsx index b3461a71693de..d2712215ed551 100644 --- a/src/plugins/index_pattern_field_editor/public/components/preview/field_preview_context.tsx +++ b/src/plugins/index_pattern_field_editor/public/components/preview/field_preview_context.tsx @@ -18,6 +18,7 @@ import React, { } from 'react'; import useDebounce from 'react-use/lib/useDebounce'; import { i18n } from '@kbn/i18n'; +import { get } from 'lodash'; import type { FieldPreviewContext, FieldFormatConfig } from '../../types'; import { parseEsError } from '../../lib/runtime_field_validation'; @@ -129,6 +130,7 @@ export const FieldPreviewProvider: FunctionComponent = ({ children }) => { const currentDocIndex = currentDocument?._index; const totalDocs = documents.length; + const { name, document, script } = params; const updateParams: Context['params']['update'] = useCallback((updated) => { setParams((prev) => ({ ...prev, ...updated })); @@ -216,7 +218,7 @@ export const FieldPreviewProvider: FunctionComponent = ({ children }) => { ); const updatePreview = useCallback(async () => { - if (fieldTypeToProcess !== 'runtime') { + if (fieldTypeToProcess !== 'runtime' || !areAllParamsDefined) { return; } @@ -264,7 +266,14 @@ export const FieldPreviewProvider: FunctionComponent = ({ children }) => { error: null, }); } - }, [fieldTypeToProcess, params, currentDocIndex, getFieldPreview, notifications.toasts]); + }, [ + fieldTypeToProcess, + areAllParamsDefined, + params, + currentDocIndex, + getFieldPreview, + notifications.toasts, + ]); const goToNextDoc = useCallback(() => { if (navDocsIndex >= totalDocs - 1) { @@ -332,17 +341,11 @@ export const FieldPreviewProvider: FunctionComponent = ({ children }) => { ); useDebounce( - () => { - if (!areAllParamsDefined) { - return; - } - - // Whenever updatePreview() changes (meaning whenever any of the params changes) - // we call it to update the preview response with the field value or possible error. - updatePreview(); - }, + // Whenever updatePreview() changes (meaning whenever any of the params changes) + // we call it to update the preview response with the field(s) value or possible error. + updatePreview, 500, - [areAllParamsDefined, updatePreview] + [updatePreview] ); /** @@ -367,6 +370,16 @@ export const FieldPreviewProvider: FunctionComponent = ({ children }) => { }); }, [currentDocument, updateParams]); + /** If the script is null and we have a document we will preview the _source value */ + useEffect(() => { + if (name && document && script === null) { + setPreviewResponse({ + fields: [{ key: name, value: get(document, name) }], + error: null, + }); + } + }, [name, document, script]); + return {children}; }; From 5b0983335b31be439c6db92ddae75f7e61f8a295 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Se=CC=81bastien=20Loix?= Date: Tue, 15 Jun 2021 15:48:09 +0100 Subject: [PATCH 19/41] Fix typo --- .../public/components/preview/field_preview_empty_prompt.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/plugins/index_pattern_field_editor/public/components/preview/field_preview_empty_prompt.tsx b/src/plugins/index_pattern_field_editor/public/components/preview/field_preview_empty_prompt.tsx index 7b39e745749ab..06eaa7f3efc85 100644 --- a/src/plugins/index_pattern_field_editor/public/components/preview/field_preview_empty_prompt.tsx +++ b/src/plugins/index_pattern_field_editor/public/components/preview/field_preview_empty_prompt.tsx @@ -30,7 +30,7 @@ export const FieldPreviewEmptyPrompt = () => {

{i18n.translate('indexPatternFieldEditor.fieldPreview.emptyPromptDescription', { defaultMessage: - 'Configure field name and value or format to see a preview of our new fields will appear.', + 'Configure field name and value or format to see a preview of how new fields will appear.', })}

From 798d4a912edb53d4ce97e1af044f9b4bd10f4a49 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Se=CC=81bastien=20Loix?= Date: Wed, 16 Jun 2021 10:19:24 +0100 Subject: [PATCH 20/41] Refactor form lib UseField test --- .../components/use_field.test.tsx | 85 ++++++++++++------- 1 file changed, 53 insertions(+), 32 deletions(-) diff --git a/src/plugins/es_ui_shared/static/forms/hook_form_lib/components/use_field.test.tsx b/src/plugins/es_ui_shared/static/forms/hook_form_lib/components/use_field.test.tsx index 46b7fd9a4cd19..2106bd50dad03 100644 --- a/src/plugins/es_ui_shared/static/forms/hook_form_lib/components/use_field.test.tsx +++ b/src/plugins/es_ui_shared/static/forms/hook_form_lib/components/use_field.test.tsx @@ -57,7 +57,7 @@ describe('', () => { describe('state', () => { describe('isPristine, isDirty, isModified', () => { // Dummy component to handle object type data - const ObjectField: React.FC<{ field: FieldHook }> = ({ field: { value, setValue } }) => { + const ObjectField: React.FC<{ field: FieldHook }> = ({ field: { setValue } }) => { const onFieldChange = (e: React.ChangeEvent) => { // Make sure to set the field value to an **object** setValue(JSON.parse(e.target.value)); @@ -66,29 +66,33 @@ describe('', () => { return ; }; - const objectInitialValue = { initial: 'value' }; - - interface PristineDirtyModifiedState { + interface FieldState { isModified: boolean; isDirty: boolean; isPristine: boolean; + value: unknown; } const getChildrenFunc = ( - cb: (state: PristineDirtyModifiedState) => void, + onStateChange: (state: FieldState) => void, Component?: React.ComponentType<{ field: FieldHook }> - ) => (field: FieldHook) => { - const { onChange, isModified, isPristine, isDirty } = field; - - // Forward the field state to our jest.fn() spy - cb({ isModified, isPristine, isDirty }); + ) => { + // This is the children passed down to the of our form + const childrenFunc = (field: FieldHook) => { + const { onChange, isModified, isPristine, isDirty, value } = field; + + // Forward the field state to our jest.fn() spy + onStateChange({ isModified, isPristine, isDirty, value }); + + // Render the child component if any (useful to test the Object field type) + return Component ? ( + + ) : ( + + ); + }; - // Render the child component if any (useful to test the Object field type) - return Component ? ( - - ) : ( - - ); + return childrenFunc; }; interface Props { @@ -104,12 +108,14 @@ describe('', () => { ); }; - const onIsModifiedChange = jest.fn(); - const lastValue = (): PristineDirtyModifiedState => - onIsModifiedChange.mock.calls[onIsModifiedChange.mock.calls.length - 1][0]; + const onStateChangeSpy = jest.fn(); + const lastFieldState = (): FieldState => + onStateChangeSpy.mock.calls[onStateChangeSpy.mock.calls.length - 1][0]; + const toString = (value: unknown): string => + typeof value === 'string' ? value : JSON.stringify(value); const setup = registerTestBed(TestComp, { - defaultProps: { onIsModifiedChange }, + defaultProps: { onStateChangeSpy }, memoryRouter: { wrapComponent: false }, }); @@ -118,14 +124,14 @@ describe('', () => { description: 'should update the state for field without default values', initialValue: '', changedValue: 'changed', - fieldProps: { children: getChildrenFunc(onIsModifiedChange) }, + fieldProps: { children: getChildrenFunc(onStateChangeSpy) }, }, { description: 'should update the state for field with default value in their config', initialValue: 'initialValue', changedValue: 'changed', fieldProps: { - children: getChildrenFunc(onIsModifiedChange), + children: getChildrenFunc(onStateChangeSpy), config: { defaultValue: 'initialValue' }, }, }, @@ -134,7 +140,7 @@ describe('', () => { initialValue: 'initialValue', changedValue: 'changed', fieldProps: { - children: getChildrenFunc(onIsModifiedChange), + children: getChildrenFunc(onStateChangeSpy), defaultValue: 'initialValue', }, }, @@ -143,30 +149,45 @@ describe('', () => { // putting back the original object { description: 'should update the state for field with object field type', - initialValue: JSON.stringify(objectInitialValue), - changedValue: JSON.stringify({ foo: 'bar' }), + initialValue: { initial: 'value' }, + changedValue: { foo: 'bar' }, fieldProps: { - children: getChildrenFunc(onIsModifiedChange, ObjectField), - defaultValue: objectInitialValue, + children: getChildrenFunc(onStateChangeSpy, ObjectField), + defaultValue: { initial: 'value' }, }, }, ].forEach(({ description, fieldProps, initialValue, changedValue }) => { test(description, async () => { const { form } = await setup({ fieldProps }); - expect(lastValue()).toEqual({ isPristine: true, isDirty: false, isModified: false }); + expect(lastFieldState()).toEqual({ + isPristine: true, + isDirty: false, + isModified: false, + value: initialValue, + }); await act(async () => { - form.setInputValue('testField', changedValue); + form.setInputValue('testField', toString(changedValue)); }); - expect(lastValue()).toEqual({ isPristine: false, isDirty: true, isModified: true }); + expect(lastFieldState()).toEqual({ + isPristine: false, + isDirty: true, + isModified: true, + value: changedValue, + }); // Put back to the initial value --> isModified should be false await act(async () => { - form.setInputValue('testField', initialValue); + form.setInputValue('testField', toString(initialValue)); + }); + expect(lastFieldState()).toEqual({ + isPristine: false, + isDirty: true, + isModified: false, + value: initialValue, }); - expect(lastValue()).toEqual({ isPristine: false, isDirty: true, isModified: false }); }); }); }); From e581514fb72da072fdd17582b12b78bcd628af80 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Loix?= Date: Wed, 16 Jun 2021 11:44:49 +0100 Subject: [PATCH 21/41] Apply suggestions from code review Co-authored-by: Michail Yasonik --- .../public/components/preview/documents_nav_preview.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/plugins/index_pattern_field_editor/public/components/preview/documents_nav_preview.tsx b/src/plugins/index_pattern_field_editor/public/components/preview/documents_nav_preview.tsx index 5e734d2791f2d..191c6e5f094c8 100644 --- a/src/plugins/index_pattern_field_editor/public/components/preview/documents_nav_preview.tsx +++ b/src/plugins/index_pattern_field_editor/public/components/preview/documents_nav_preview.tsx @@ -128,7 +128,7 @@ export const DocumentsNavPreview = () => { aria-label={i18n.translate( 'indexPatternFieldEditor.fieldPreview.documentNav.previousArialabel', { - defaultMessage: 'Previous', + defaultMessage: 'Previous document', } )} /> @@ -142,7 +142,7 @@ export const DocumentsNavPreview = () => { aria-label={i18n.translate( 'indexPatternFieldEditor.fieldPreview.documentNav.nextArialabel', { - defaultMessage: 'Next', + defaultMessage: 'Next document', } )} /> From 97c18a247112ab13b04fd57dffe6b387cd145503 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Se=CC=81bastien=20Loix?= Date: Wed, 16 Jun 2021 12:20:48 +0100 Subject: [PATCH 22/41] Use better naming for painless error testBed --- .../client_integration/field_editor.test.tsx | 34 +++++++++++-------- 1 file changed, 19 insertions(+), 15 deletions(-) diff --git a/src/plugins/index_pattern_field_editor/__jest__/client_integration/field_editor.test.tsx b/src/plugins/index_pattern_field_editor/__jest__/client_integration/field_editor.test.tsx index d7bb8ec6280e5..729e12c035f43 100644 --- a/src/plugins/index_pattern_field_editor/__jest__/client_integration/field_editor.test.tsx +++ b/src/plugins/index_pattern_field_editor/__jest__/client_integration/field_editor.test.tsx @@ -191,13 +191,14 @@ describe('', () => { script: { source: 'emit(6)' }, }; - const TestComponent = () => { - const dummyError = { - reason: 'Awwww! Painless syntax error', - message: '', - position: { offset: 0, start: 0, end: 0 }, - scriptStack: [''], - }; + const dummyError = { + reason: 'Awwww! Painless syntax error', + message: '', + position: { offset: 0, start: 0, end: 0 }, + scriptStack: [''], + }; + + const ComponentToProvidePainlessSyntaxErrors = () => { const [error, setError] = useState(null); const clearError = useMemo(() => () => setError(null), []); const syntaxError = useMemo(() => ({ error, clear: clearError }), [error, clearError]); @@ -214,19 +215,22 @@ describe('', () => { ); }; - let customTestbed: TestBed; + let testBedToCapturePainlessErrors: TestBed; await act(async () => { - customTestbed = await registerTestBed(WithFieldEditorDependencies(TestComponent), { - memoryRouter: { - wrapComponent: false, - }, - })(); + testBedToCapturePainlessErrors = await registerTestBed( + WithFieldEditorDependencies(ComponentToProvidePainlessSyntaxErrors), + { + memoryRouter: { + wrapComponent: false, + }, + } + )(); }); testBed = { - ...customTestbed!, - actions: getCommonActions(customTestbed!), + ...testBedToCapturePainlessErrors!, + actions: getCommonActions(testBedToCapturePainlessErrors!), }; const { From 4c05e3ae1c17e92b37ccf5203612e8e226915408 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Se=CC=81bastien=20Loix?= Date: Wed, 16 Jun 2021 14:30:17 +0100 Subject: [PATCH 23/41] Address CR changes --- .../helpers/setup_environment.tsx | 4 ++-- .../preview/documents_nav_preview.tsx | 18 +++++++++--------- .../components/preview/field_preview.tsx | 14 ++++++++------ .../components/preview/field_preview_error.tsx | 5 ++++- .../preview/field_preview_header.tsx | 9 ++++----- 5 files changed, 27 insertions(+), 23 deletions(-) diff --git a/src/plugins/index_pattern_field_editor/__jest__/client_integration/helpers/setup_environment.tsx b/src/plugins/index_pattern_field_editor/__jest__/client_integration/helpers/setup_environment.tsx index c051dc42a57b2..ff776140b688e 100644 --- a/src/plugins/index_pattern_field_editor/__jest__/client_integration/helpers/setup_environment.tsx +++ b/src/plugins/index_pattern_field_editor/__jest__/client_integration/helpers/setup_environment.tsx @@ -13,7 +13,7 @@ import axios from 'axios'; import axiosXhrAdapter from 'axios/lib/adapters/xhr'; import { merge } from 'lodash'; -import { notificationServiceMock } from '../../../../../core/public/mocks'; +import { notificationServiceMock, uiSettingsServiceMock } from '../../../../../core/public/mocks'; import { dataPluginMock } from '../../../../data/public/mocks'; import { FieldEditorProvider, Context } from '../../../public/components/field_editor_context'; import { FieldPreviewProvider } from '../../../public/components/preview'; @@ -68,7 +68,7 @@ export const WithFieldEditorDependencies = indexPatternFields }, } as any, - uiSettings: {} as any, + uiSettings: uiSettingsServiceMock.createStartContract(), fieldTypeToProcess: 'runtime', existingConcreteFields: [], namesNotAllowed: [], diff --git a/src/plugins/index_pattern_field_editor/public/components/preview/documents_nav_preview.tsx b/src/plugins/index_pattern_field_editor/public/components/preview/documents_nav_preview.tsx index 5e734d2791f2d..25d6a26a58771 100644 --- a/src/plugins/index_pattern_field_editor/public/components/preview/documents_nav_preview.tsx +++ b/src/plugins/index_pattern_field_editor/public/components/preview/documents_nav_preview.tsx @@ -28,7 +28,7 @@ export const DocumentsNavPreview = () => { const lastDocumentLoaded = useRef(null); const [documentId, setDocumentId] = useState(''); - const [isCustomID, setIsCustomID] = useState(false); + const [isCustomId, setIsCustomId] = useState(false); const errorMessage = error !== null && error.code === 'DOC_NOT_FOUND' @@ -43,29 +43,29 @@ export const DocumentsNavPreview = () => { // We don't display the nav button when the user has entered a custom // document ID as at that point there is no more reference to what's "next" - const showNavButtons = isCustomID === false; + const showNavButtons = isCustomId === false; const onDocumentIdChange = useCallback((e: React.SyntheticEvent) => { - setIsCustomID(true); + setIsCustomId(true); const nextId = e.currentTarget.value; setDocumentId(nextId); }, []); const loadDocFromCluster = useCallback(() => { lastDocumentLoaded.current = null; - setIsCustomID(false); + setIsCustomId(false); loadFromCluster(); }, [loadFromCluster]); useEffect(() => { - if (currentDocument && !isCustomID) { + if (currentDocument && !isCustomId) { setDocumentId(currentDocument._id); } - }, [currentDocument, isCustomID]); + }, [currentDocument, isCustomId]); useDebounce( () => { - if (!isCustomID || !Boolean(documentId.trim())) { + if (!isCustomId || !Boolean(documentId.trim())) { return; } @@ -78,7 +78,7 @@ export const DocumentsNavPreview = () => { loadSingle(documentId); }, 500, - [documentId, isCustomID] + [documentId, isCustomId] ); return ( @@ -102,7 +102,7 @@ export const DocumentsNavPreview = () => { data-test-subj="documentIdField" /> - {isCustomID && ( + {isCustomId && ( {i18n.translate( diff --git a/src/plugins/index_pattern_field_editor/public/components/preview/field_preview.tsx b/src/plugins/index_pattern_field_editor/public/components/preview/field_preview.tsx index c55b8c5d3dd97..39458894666c3 100644 --- a/src/plugins/index_pattern_field_editor/public/components/preview/field_preview.tsx +++ b/src/plugins/index_pattern_field_editor/public/components/preview/field_preview.tsx @@ -49,12 +49,14 @@ export const FieldPreview = () => { } return ( -
- -
+
    +
  • + +
  • +
); }; diff --git a/src/plugins/index_pattern_field_editor/public/components/preview/field_preview_error.tsx b/src/plugins/index_pattern_field_editor/public/components/preview/field_preview_error.tsx index 2fc3d5885b66b..c2a28a175ed7c 100644 --- a/src/plugins/index_pattern_field_editor/public/components/preview/field_preview_error.tsx +++ b/src/plugins/index_pattern_field_editor/public/components/preview/field_preview_error.tsx @@ -7,6 +7,7 @@ */ import React from 'react'; import { EuiCallOut } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; import { useFieldPreviewContext } from './field_preview_context'; @@ -19,7 +20,9 @@ export const FieldPreviewError = () => { return ( { -

- {i18n.translate('indexPatternFieldEditor.fieldPreview.title', { - defaultMessage: 'Preview', - })} -

+

{i18nTexts.title}

From 1970973bdb076489afdd784d4c6f4c2b709c7848 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Se=CC=81bastien=20Loix?= Date: Wed, 16 Jun 2021 14:46:48 +0100 Subject: [PATCH 24/41] Revert changes to flyout_service and add missing prop --- src/core/public/overlays/flyout/flyout_service.tsx | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/src/core/public/overlays/flyout/flyout_service.tsx b/src/core/public/overlays/flyout/flyout_service.tsx index 78089aa45fa5a..b41b85e5f429f 100644 --- a/src/core/public/overlays/flyout/flyout_service.tsx +++ b/src/core/public/overlays/flyout/flyout_service.tsx @@ -8,7 +8,7 @@ /* eslint-disable max-classes-per-file */ -import { EuiFlyout, EuiFlyoutProps } from '@elastic/eui'; +import { EuiFlyout, EuiFlyoutSize } from '@elastic/eui'; import React from 'react'; import { render, unmountComponentAtNode } from 'react-dom'; import { Subject } from 'rxjs'; @@ -77,7 +77,15 @@ export interface OverlayFlyoutStart { /** * @public */ -export type OverlayFlyoutOpenOptions = Omit; +export interface OverlayFlyoutOpenOptions { + className?: string; + closeButtonAriaLabel?: string; + ownFocus?: boolean; + 'data-test-subj'?: string; + size?: EuiFlyoutSize; + maxWidth?: boolean | number | string; + hideCloseButton?: boolean; +} interface StartDeps { i18n: I18nStart; From 230100bb3e6d638977fa87aade0be1bd3e7b6130 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Se=CC=81bastien=20Loix?= Date: Wed, 16 Jun 2021 15:11:13 +0100 Subject: [PATCH 25/41] Create component for confirm modal when saving field with name or type change --- .../public/components/confirm_modals/index.ts | 2 + .../save_field_type_or_name_changed_modal.tsx | 86 +++++++++++++++++ .../field_editor_flyout_content.tsx | 95 ++++--------------- 3 files changed, 105 insertions(+), 78 deletions(-) create mode 100644 src/plugins/index_pattern_field_editor/public/components/confirm_modals/save_field_type_or_name_changed_modal.tsx diff --git a/src/plugins/index_pattern_field_editor/public/components/confirm_modals/index.ts b/src/plugins/index_pattern_field_editor/public/components/confirm_modals/index.ts index f82558904190a..2283070f6f727 100644 --- a/src/plugins/index_pattern_field_editor/public/components/confirm_modals/index.ts +++ b/src/plugins/index_pattern_field_editor/public/components/confirm_modals/index.ts @@ -9,3 +9,5 @@ export { DeleteFieldModal } from './delete_field_modal'; export { ModifiedFieldModal } from './modified_field_modal'; + +export { SaveFieldTypeOrNameChangedModal } from './save_field_type_or_name_changed_modal'; diff --git a/src/plugins/index_pattern_field_editor/public/components/confirm_modals/save_field_type_or_name_changed_modal.tsx b/src/plugins/index_pattern_field_editor/public/components/confirm_modals/save_field_type_or_name_changed_modal.tsx new file mode 100644 index 0000000000000..51af86868c632 --- /dev/null +++ b/src/plugins/index_pattern_field_editor/public/components/confirm_modals/save_field_type_or_name_changed_modal.tsx @@ -0,0 +1,86 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ +import React, { useState } from 'react'; +import { EuiCallOut, EuiSpacer, EuiConfirmModal, EuiFieldText, EuiFormRow } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; + +const geti18nTexts = (fieldName: string) => ({ + cancelButtonText: i18n.translate( + 'indexPatternFieldEditor.saveRuntimeField.confirmationModal.cancelButtonLabel', + { + defaultMessage: 'Cancel', + } + ), + confirmButtonText: i18n.translate( + 'indexPatternFieldEditor.deleteRuntimeField.confirmationModal.saveButtonLabel', + { + defaultMessage: 'Save changes', + } + ), + warningChangingFields: i18n.translate( + 'indexPatternFieldEditor.deleteRuntimeField.confirmModal.warningChangingFields', + { + defaultMessage: + 'Changing name or type can break searches and visualizations that rely on this field.', + } + ), + typeConfirm: i18n.translate('indexPatternFieldEditor.saveRuntimeField.confirmModal.typeConfirm', { + defaultMessage: 'Enter CHANGE to continue', + }), + titleConfirmChanges: i18n.translate( + 'indexPatternFieldEditor.saveRuntimeField.confirmModal.title', + { + defaultMessage: `Save changes to '{name}'`, + values: { + name: fieldName, + }, + } + ), +}); + +interface Props { + fieldName: string; + onConfirm: () => void; + onCancel: () => void; +} + +export const SaveFieldTypeOrNameChangedModal: React.FC = ({ + fieldName, + onCancel, + onConfirm, +}) => { + const i18nTexts = geti18nTexts(fieldName); + const [confirmContent, setConfirmContent] = useState(''); + + return ( + + + + + setConfirmContent(e.target.value)} + data-test-subj="saveModalConfirmText" + /> + + + ); +}; diff --git a/src/plugins/index_pattern_field_editor/public/components/field_editor_flyout_content.tsx b/src/plugins/index_pattern_field_editor/public/components/field_editor_flyout_content.tsx index c217b530e40df..5df9db0bec8e4 100644 --- a/src/plugins/index_pattern_field_editor/public/components/field_editor_flyout_content.tsx +++ b/src/plugins/index_pattern_field_editor/public/components/field_editor_flyout_content.tsx @@ -18,9 +18,6 @@ import { EuiCallOut, EuiSpacer, EuiText, - EuiConfirmModal, - EuiFieldText, - EuiFormRow, } from '@elastic/eui'; import type { Field, EsRuntimeField } from '../types'; @@ -30,54 +27,18 @@ import { FlyoutPanels } from './flyout_panels'; import { useFieldEditorContext } from './field_editor_context'; import { FieldEditor, FieldEditorFormState } from './field_editor/field_editor'; import { FieldPreview, useFieldPreviewContext } from './preview'; -import { ModifiedFieldModal } from './confirm_modals'; +import { ModifiedFieldModal, SaveFieldTypeOrNameChangedModal } from './confirm_modals'; -const geti18nTexts = (field?: Field) => { - return { - cancelButtonLabel: i18n.translate('indexPatternFieldEditor.editor.flyoutCancelButtonLabel', { - defaultMessage: 'Cancel', - }), - saveButtonLabel: i18n.translate('indexPatternFieldEditor.editor.flyoutSaveButtonLabel', { - defaultMessage: 'Save', - }), - formErrorsCalloutTitle: i18n.translate('indexPatternFieldEditor.editor.validationErrorTitle', { - defaultMessage: 'Fix errors in form before continuing.', - }), - cancelButtonText: i18n.translate( - 'indexPatternFieldEditor.saveRuntimeField.confirmationModal.cancelButtonLabel', - { - defaultMessage: 'Cancel', - } - ), - confirmButtonText: i18n.translate( - 'indexPatternFieldEditor.deleteRuntimeField.confirmationModal.saveButtonLabel', - { - defaultMessage: 'Save changes', - } - ), - warningChangingFields: i18n.translate( - 'indexPatternFieldEditor.deleteRuntimeField.confirmModal.warningChangingFields', - { - defaultMessage: - 'Changing name or type can break searches and visualizations that rely on this field.', - } - ), - typeConfirm: i18n.translate( - 'indexPatternFieldEditor.saveRuntimeField.confirmModal.typeConfirm', - { - defaultMessage: 'Enter CHANGE to continue', - } - ), - titleConfirmChanges: i18n.translate( - 'indexPatternFieldEditor.saveRuntimeField.confirmModal.title', - { - defaultMessage: `Save changes to '{name}'`, - values: { - name: field?.name, - }, - } - ), - }; +const i18nTexts = { + cancelButtonLabel: i18n.translate('indexPatternFieldEditor.editor.flyoutCancelButtonLabel', { + defaultMessage: 'Cancel', + }), + saveButtonLabel: i18n.translate('indexPatternFieldEditor.editor.flyoutSaveButtonLabel', { + defaultMessage: 'Save', + }), + formErrorsCalloutTitle: i18n.translate('indexPatternFieldEditor.editor.validationErrorTitle', { + defaultMessage: 'Fix errors in form before continuing.', + }), }; const defaultModalVisibility = { @@ -109,7 +70,6 @@ const FieldEditorFlyoutContentComponent = ({ isSavingField, }: Props) => { const isEditingExistingField = !!field; - const i18nTexts = geti18nTexts(field); const { indexPattern } = useFieldEditorContext(); const { panel: { isVisible: isPanelVisible }, @@ -129,7 +89,6 @@ const FieldEditorFlyoutContentComponent = ({ const [isValidating, setIsValidating] = useState(false); const [modalVisibility, setModalVisibility] = useState(defaultModalVisibility); - const [confirmContent, setConfirmContent] = useState(''); const [isFormModified, setIsFormModified] = useState(false); const { submit, isValid: isFormValid, isSubmitted } = formState; @@ -193,36 +152,16 @@ const FieldEditorFlyoutContentComponent = ({ const renderModal = () => { if (modalVisibility.confirmChangeNameOrType) { return ( - { - setModalVisibility(defaultModalVisibility); - setConfirmContent(''); - }} + { const { data } = await submit(); onSave(data); }} - > - - - - setConfirmContent(e.target.value)} - data-test-subj="saveModalConfirmText" - /> - - + onCancel={() => { + setModalVisibility(defaultModalVisibility); + }} + /> ); } From c5c5d739a350ad7a960d07f06764c3d51f92910d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Se=CC=81bastien=20Loix?= Date: Thu, 17 Jun 2021 12:18:46 +0100 Subject: [PATCH 26/41] Fix issue with stale state after fetching unknown document --- .../components/field_editor/field_editor.tsx | 4 +- .../components/preview/field_preview.tsx | 9 +++- .../preview/field_preview_context.tsx | 51 +++++++++++++++++-- 3 files changed, 57 insertions(+), 7 deletions(-) diff --git a/src/plugins/index_pattern_field_editor/public/components/field_editor/field_editor.tsx b/src/plugins/index_pattern_field_editor/public/components/field_editor/field_editor.tsx index 3917248bcc403..f3898f2921d94 100644 --- a/src/plugins/index_pattern_field_editor/public/components/field_editor/field_editor.tsx +++ b/src/plugins/index_pattern_field_editor/public/components/field_editor/field_editor.tsx @@ -68,8 +68,8 @@ export interface Props { field?: Field; /** Handler to receive state changes updates */ onChange?: (state: FieldEditorFormState) => void; - /** Handler to receive update on the form "dirty" state */ - onFormModifiedChange?: (isDirty: boolean) => void; + /** Handler to receive update on the form "isModified" state */ + onFormModifiedChange?: (isModified: boolean) => void; syntaxError: ScriptSyntaxError; } diff --git a/src/plugins/index_pattern_field_editor/public/components/preview/field_preview.tsx b/src/plugins/index_pattern_field_editor/public/components/preview/field_preview.tsx index 39458894666c3..267aeca903908 100644 --- a/src/plugins/index_pattern_field_editor/public/components/preview/field_preview.tsx +++ b/src/plugins/index_pattern_field_editor/public/components/preview/field_preview.tsx @@ -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 } from 'react'; +import React, { useState, useCallback, useEffect } from 'react'; import { EuiSpacer, EuiResizeObserver } from '@elastic/eui'; import { useFieldPreviewContext } from './field_preview_context'; @@ -28,6 +28,7 @@ export const FieldPreview = () => { previewCount, fields, error, + reset, } = useFieldPreviewContext(); // To show the preview we at least need a name to be defined, the script or the format @@ -60,6 +61,12 @@ export const FieldPreview = () => { ); }; + useEffect(() => { + // When unmounting the preview pannel we make sure to reset + // the state of the preview panel. + return reset; + }, [reset]); + return (
{isEmptyPromptVisible ? ( diff --git a/src/plugins/index_pattern_field_editor/public/components/preview/field_preview_context.tsx b/src/plugins/index_pattern_field_editor/public/components/preview/field_preview_context.tsx index d2712215ed551..97c295ee784c4 100644 --- a/src/plugins/index_pattern_field_editor/public/components/preview/field_preview_context.tsx +++ b/src/plugins/index_pattern_field_editor/public/components/preview/field_preview_context.tsx @@ -30,7 +30,7 @@ type From = 'cluster' | 'custom'; type EsDocument = Record; interface PreviewError { - code: 'DOC_NOT_FOUND' | 'PAINLESS_SCRIPT_ERROR'; + code: 'DOC_NOT_FOUND' | 'PAINLESS_SCRIPT_ERROR' | 'ERR_FETCHING_DOC'; error: Record; } @@ -73,6 +73,7 @@ interface Context { next: () => void; prev: () => void; }; + reset: () => void; } const fieldPreviewContext = createContext(undefined); @@ -170,7 +171,7 @@ export const FieldPreviewProvider: FunctionComponent = ({ children }) => { setPreviewResponse({ fields: [], error: null }); setIsFetchingDocument(true); - const response = await search + const [response, error] = await search .search({ params: { index: indexPattern.title, @@ -184,16 +185,19 @@ export const FieldPreviewProvider: FunctionComponent = ({ children }) => { }, }, }) - .toPromise(); + .toPromise() + .then((res) => [res, null]) + .catch((err) => [null, err]); setIsFetchingDocument(false); - setNavDocsIndex(0); if (response) { if (response.rawResponse.hits.total > 0) { setDocuments(response.rawResponse.hits.hits); + setNavDocsIndex(0); } else { setDocuments([]); + setNavDocsIndex(-1); setPreviewResponse({ fields: [], error: { @@ -210,8 +214,21 @@ export const FieldPreviewProvider: FunctionComponent = ({ children }) => { }, }); } + } else if (error) { + // TODO: improve this error handling when there is a server + // error fetching a document + setPreviewResponse({ + fields: [], + error: { + code: 'ERR_FETCHING_DOC', + error: { + message: error.toString(), + }, + }, + }); } else { setDocuments([]); + setNavDocsIndex(-1); } }, [indexPattern, search] @@ -222,6 +239,8 @@ export const FieldPreviewProvider: FunctionComponent = ({ children }) => { return; } + const currentApiCall = previewCount.current; + setIsLoadingPreview(true); const response = await getFieldPreview({ @@ -231,6 +250,12 @@ export const FieldPreviewProvider: FunctionComponent = ({ children }) => { script: params.script!, }); + if (currentApiCall !== previewCount.current) { + // Discard this response as there is another one inflight + // or we have called reset() and don't need the response anymore. + return; + } + previewCount.current = ++previewCount.current; setIsLoadingPreview(false); @@ -291,6 +316,22 @@ export const FieldPreviewProvider: FunctionComponent = ({ children }) => { setIsLoadingPreview(true); }, [navDocsIndex, totalDocs]); + const reset = useCallback(() => { + // We increase the count of preview calls to discard any inflight + // API call response coming in after calling reset() + previewCount.current = ++previewCount.current; + + setNavDocsIndex(0); + setFrom('cluster'); + setPreviewResponse({ fields: [], error: null }); + setIsLoadingPreview(false); + setIsFetchingDocument(false); + + if (documents.length === 0) { + fetchSampleDocuments(); + } + }, [documents, fetchSampleDocuments]); + const ctx = useMemo( () => ({ fields: previewResponse.fields, @@ -321,6 +362,7 @@ export const FieldPreviewProvider: FunctionComponent = ({ children }) => { value: from, set: setFrom, }, + reset, }), [ previewResponse, @@ -337,6 +379,7 @@ export const FieldPreviewProvider: FunctionComponent = ({ children }) => { goToPrevDoc, isPanelVisible, from, + reset, ] ); From 5e677e87deb51b2862f2e1a73806ff48b516e8e0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Se=CC=81bastien=20Loix?= Date: Thu, 17 Jun 2021 12:31:53 +0100 Subject: [PATCH 27/41] Fix "updating..." state not being reset --- .../public/components/preview/field_preview_context.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/src/plugins/index_pattern_field_editor/public/components/preview/field_preview_context.tsx b/src/plugins/index_pattern_field_editor/public/components/preview/field_preview_context.tsx index 97c295ee784c4..f7e8abccc08d8 100644 --- a/src/plugins/index_pattern_field_editor/public/components/preview/field_preview_context.tsx +++ b/src/plugins/index_pattern_field_editor/public/components/preview/field_preview_context.tsx @@ -420,6 +420,7 @@ export const FieldPreviewProvider: FunctionComponent = ({ children }) => { fields: [{ key: name, value: get(document, name) }], error: null, }); + setIsLoadingPreview(false); } }, [name, document, script]); From 0386a9b89834acd88f22b5d7c8b6fb1477c46b80 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Se=CC=81bastien=20Loix?= Date: Thu, 17 Jun 2021 13:07:05 +0100 Subject: [PATCH 28/41] Move change to isModified into dedicated useEffect --- .../forms/hook_form_lib/hooks/use_field.ts | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/use_field.ts b/src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/use_field.ts index 7a897a44b8fad..5362a8d440987 100644 --- a/src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/use_field.ts +++ b/src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/use_field.ts @@ -127,12 +127,6 @@ export const useField = ( const changeIteration = ++changeCounter.current; const startTime = Date.now(); - setIsModified(() => { - if (typeof value === 'object') { - return JSON.stringify(value) !== JSON.stringify(initialValue); - } - return value !== initialValue; - }); setPristine(false); setIsChangingValue(true); @@ -176,7 +170,6 @@ export const useField = ( }, [ path, value, - initialValue, valueChangeListener, valueChangeDebounceTime, fieldsToValidateOnChange, @@ -576,6 +569,15 @@ export const useField = ( }; }, [onValueChange]); + useEffect(() => { + setIsModified(() => { + if (typeof value === 'object') { + return JSON.stringify(value) !== JSON.stringify(initialValue); + } + return value !== initialValue; + }); + }, [value, initialValue]); + useEffect(() => { if (!isMounted.current) { return; From bdbe126c756f5c665cdc903527aaa770afdf7309 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Se=CC=81bastien=20Loix?= Date: Thu, 17 Jun 2021 13:25:55 +0100 Subject: [PATCH 29/41] Fix TS issue --- x-pack/plugins/cases/public/common/mock/test_providers.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/x-pack/plugins/cases/public/common/mock/test_providers.tsx b/x-pack/plugins/cases/public/common/mock/test_providers.tsx index 9a08918a483a5..ff1217125926d 100644 --- a/x-pack/plugins/cases/public/common/mock/test_providers.tsx +++ b/x-pack/plugins/cases/public/common/mock/test_providers.tsx @@ -46,6 +46,8 @@ export const useFormFieldMock = (options?: Partial>): FieldHook type: 'type', value: ('mockedValue' as unknown) as T, isPristine: false, + isDirty: false, + isModified: false, isValidating: false, isValidated: false, isChangingValue: false, From 639b381c44dd19387f4f0bed8c3ea38f40895774 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Se=CC=81bastien=20Loix?= Date: Thu, 17 Jun 2021 13:30:18 +0100 Subject: [PATCH 30/41] Add missing core public API --- ...public.overlayflyoutopenoptions.hideclosebutton.md | 11 +++++++++++ ...ana-plugin-core-public.overlayflyoutopenoptions.md | 1 + src/core/public/public.api.md | 2 ++ 3 files changed, 14 insertions(+) create mode 100644 docs/development/core/public/kibana-plugin-core-public.overlayflyoutopenoptions.hideclosebutton.md diff --git a/docs/development/core/public/kibana-plugin-core-public.overlayflyoutopenoptions.hideclosebutton.md b/docs/development/core/public/kibana-plugin-core-public.overlayflyoutopenoptions.hideclosebutton.md new file mode 100644 index 0000000000000..149a53f35d34d --- /dev/null +++ b/docs/development/core/public/kibana-plugin-core-public.overlayflyoutopenoptions.hideclosebutton.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [OverlayFlyoutOpenOptions](./kibana-plugin-core-public.overlayflyoutopenoptions.md) > [hideCloseButton](./kibana-plugin-core-public.overlayflyoutopenoptions.hideclosebutton.md) + +## OverlayFlyoutOpenOptions.hideCloseButton property + +Signature: + +```typescript +hideCloseButton?: boolean; +``` diff --git a/docs/development/core/public/kibana-plugin-core-public.overlayflyoutopenoptions.md b/docs/development/core/public/kibana-plugin-core-public.overlayflyoutopenoptions.md index 6665ebde295bc..fc4959b87a987 100644 --- a/docs/development/core/public/kibana-plugin-core-public.overlayflyoutopenoptions.md +++ b/docs/development/core/public/kibana-plugin-core-public.overlayflyoutopenoptions.md @@ -18,6 +18,7 @@ export interface OverlayFlyoutOpenOptions | ["data-test-subj"](./kibana-plugin-core-public.overlayflyoutopenoptions._data-test-subj_.md) | string | | | [className](./kibana-plugin-core-public.overlayflyoutopenoptions.classname.md) | string | | | [closeButtonAriaLabel](./kibana-plugin-core-public.overlayflyoutopenoptions.closebuttonarialabel.md) | string | | +| [hideCloseButton](./kibana-plugin-core-public.overlayflyoutopenoptions.hideclosebutton.md) | boolean | | | [maxWidth](./kibana-plugin-core-public.overlayflyoutopenoptions.maxwidth.md) | boolean | number | string | | | [ownFocus](./kibana-plugin-core-public.overlayflyoutopenoptions.ownfocus.md) | boolean | | | [size](./kibana-plugin-core-public.overlayflyoutopenoptions.size.md) | EuiFlyoutSize | | diff --git a/src/core/public/public.api.md b/src/core/public/public.api.md index d3426b50f7614..d411a90274590 100644 --- a/src/core/public/public.api.md +++ b/src/core/public/public.api.md @@ -970,6 +970,8 @@ export interface OverlayFlyoutOpenOptions { // (undocumented) closeButtonAriaLabel?: string; // (undocumented) + hideCloseButton?: boolean; + // (undocumented) maxWidth?: boolean | number | string; // (undocumented) ownFocus?: boolean; From 871b065238e4d2e42accaa2a275b3dc23c5d0187 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Se=CC=81bastien=20Loix?= Date: Thu, 17 Jun 2021 14:03:09 +0100 Subject: [PATCH 31/41] Fix TS issue --- .../security_solution/public/common/mock/test_providers.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/x-pack/plugins/security_solution/public/common/mock/test_providers.tsx b/x-pack/plugins/security_solution/public/common/mock/test_providers.tsx index 9ac7ae0f24322..aa0a3271ff547 100644 --- a/x-pack/plugins/security_solution/public/common/mock/test_providers.tsx +++ b/x-pack/plugins/security_solution/public/common/mock/test_providers.tsx @@ -91,6 +91,8 @@ export const useFormFieldMock = (options?: Partial>): FieldHook type: 'type', value: ('mockedValue' as unknown) as T, isPristine: false, + isDirty: false, + isModified: false, isValidating: false, isValidated: false, isChangingValue: false, From 751a0d1cb949e56ad673e8beec953dbc83d208bb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Se=CC=81bastien=20Loix?= Date: Thu, 17 Jun 2021 15:12:15 +0100 Subject: [PATCH 32/41] Update a11y tests --- .../public/components/field_editor_flyout_content.tsx | 7 ++++++- test/accessibility/apps/management.ts | 2 +- test/functional/page_objects/settings_page.ts | 10 +++++++++- 3 files changed, 16 insertions(+), 3 deletions(-) diff --git a/src/plugins/index_pattern_field_editor/public/components/field_editor_flyout_content.tsx b/src/plugins/index_pattern_field_editor/public/components/field_editor_flyout_content.tsx index 5df9db0bec8e4..5f3627177a92c 100644 --- a/src/plugins/index_pattern_field_editor/public/components/field_editor_flyout_content.tsx +++ b/src/plugins/index_pattern_field_editor/public/components/field_editor_flyout_content.tsx @@ -184,7 +184,12 @@ const FieldEditorFlyoutContentComponent = ({ return ( <> - + {/* Editor panel */} diff --git a/test/accessibility/apps/management.ts b/test/accessibility/apps/management.ts index e71f6bb3ebfee..c3784aa0ccaa5 100644 --- a/test/accessibility/apps/management.ts +++ b/test/accessibility/apps/management.ts @@ -61,7 +61,6 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await a11y.testAppSnapshot(); - await testSubjects.click('euiFlyoutCloseButton'); await PageObjects.settings.closeIndexPatternFieldEditor(); }); @@ -82,6 +81,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { it('Edit field type', async () => { await PageObjects.settings.clickEditFieldFormat(); await a11y.testAppSnapshot(); + await PageObjects.settings.closeIndexPatternFieldEditor(); }); it('Advanced settings', async () => { diff --git a/test/functional/page_objects/settings_page.ts b/test/functional/page_objects/settings_page.ts index 88951bb04c956..bdd561baca94d 100644 --- a/test/functional/page_objects/settings_page.ts +++ b/test/functional/page_objects/settings_page.ts @@ -536,8 +536,16 @@ export class SettingsPageObject extends FtrService { } async closeIndexPatternFieldEditor() { + await this.testSubjects.click('closeFlyoutButton'); + + // We might have unsaved changes and we need to confirm inside the modal + if (await this.testSubjects.exists('runtimeFieldModifiedFieldConfirmModal')) { + this.log.debug('Unsaved changes for the field: need to confirm'); + await this.testSubjects.click('confirmModalConfirmButton'); + } + await this.retry.waitFor('field editor flyout to close', async () => { - return !(await this.testSubjects.exists('euiFlyoutCloseButton')); + return !(await this.testSubjects.exists('fieldEditor')); }); } From 7b69cc4d6c2763b89c76def69427aa18c571e837 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Se=CC=81bastien=20Loix?= Date: Fri, 18 Jun 2021 10:19:54 +0100 Subject: [PATCH 33/41] Refactor logic to fetch document to fix tests --- .../field_editor_flyout_content.tsx | 2 +- .../flyout_panels/flyout_panels.tsx | 1 - .../preview/field_preview_context.tsx | 115 ++++++++++-------- 3 files changed, 67 insertions(+), 51 deletions(-) diff --git a/src/plugins/index_pattern_field_editor/public/components/field_editor_flyout_content.tsx b/src/plugins/index_pattern_field_editor/public/components/field_editor_flyout_content.tsx index 5f3627177a92c..76f8c2e655fe6 100644 --- a/src/plugins/index_pattern_field_editor/public/components/field_editor_flyout_content.tsx +++ b/src/plugins/index_pattern_field_editor/public/components/field_editor_flyout_content.tsx @@ -277,7 +277,7 @@ const FieldEditorFlyoutContentComponent = ({ {/* Preview panel */} {isPanelVisible && ( - + )} diff --git a/src/plugins/index_pattern_field_editor/public/components/flyout_panels/flyout_panels.tsx b/src/plugins/index_pattern_field_editor/public/components/flyout_panels/flyout_panels.tsx index c029476ebcc48..95fb44b293e00 100644 --- a/src/plugins/index_pattern_field_editor/public/components/flyout_panels/flyout_panels.tsx +++ b/src/plugins/index_pattern_field_editor/public/components/flyout_panels/flyout_panels.tsx @@ -66,7 +66,6 @@ export const Panels: React.FC = ({ const el = document.getElementsByClassName(flyoutClassName); if (el.length === 0) { - // throw new Error(`Flyout with className "${flyoutClassName}" not found.`); return null; } diff --git a/src/plugins/index_pattern_field_editor/public/components/preview/field_preview_context.tsx b/src/plugins/index_pattern_field_editor/public/components/preview/field_preview_context.tsx index f7e8abccc08d8..dd453b4ee6b21 100644 --- a/src/plugins/index_pattern_field_editor/public/components/preview/field_preview_context.tsx +++ b/src/plugins/index_pattern_field_editor/public/components/preview/field_preview_context.tsx @@ -26,7 +26,6 @@ import { RuntimeType, RuntimeField } from '../../shared_imports'; import { useFieldEditorContext } from '../field_editor_context'; type From = 'cluster' | 'custom'; - type EsDocument = Record; interface PreviewError { @@ -34,6 +33,12 @@ interface PreviewError { error: Record; } +interface ClusterData { + documents: EsDocument[]; + currentIdx: number; +} + +// The parameters required to preview the field interface Params { name: string | null; index: string | null; @@ -107,9 +112,10 @@ export const FieldPreviewProvider: FunctionComponent = ({ children }) => { /** The parameters required for the Painless _execute API */ const [params, setParams] = useState(defaultParams); /** The sample documents fetched from the cluster */ - const [documents, setDocuments] = useState([]); - /** The current Array index of the document we are previewing (when previewing from the cluster) */ - const [navDocsIndex, setNavDocsIndex] = useState(0); + const [clusterData, setClusterData] = useState({ + documents: [], + currentIdx: 0, + }); /** Flag to show/hide the preview panel */ const [isPanelVisible, setIsPanelVisible] = useState(false); /** Flag to indicate if we are loading document from cluster */ @@ -124,14 +130,15 @@ export const FieldPreviewProvider: FunctionComponent = ({ children }) => { .filter(([key]) => key !== 'format') .every(([_, value]) => Boolean(value)); - const currentDocument: Record | undefined = useMemo(() => documents[navDocsIndex], [ + const { documents, currentIdx } = clusterData; + const currentDocument: Record | undefined = useMemo(() => documents[currentIdx], [ documents, - navDocsIndex, + currentIdx, ]); const currentDocIndex = currentDocument?._index; const totalDocs = documents.length; - const { name, document, script } = params; + const { name, document, script, format } = params; const updateParams: Context['params']['update'] = useCallback((updated) => { setParams((prev) => ({ ...prev, ...updated })); @@ -139,7 +146,6 @@ export const FieldPreviewProvider: FunctionComponent = ({ children }) => { const fetchSampleDocuments = useCallback( async (limit = 50) => { - setPreviewResponse({ fields: [], error: null }); setIsFetchingDocument(true); setIsLoadingPreview(true); @@ -155,20 +161,18 @@ export const FieldPreviewProvider: FunctionComponent = ({ children }) => { .toPromise(); setIsFetchingDocument(false); - setNavDocsIndex(0); - if (response) { - setDocuments(response.rawResponse.hits.hits); - } else { - setDocuments([]); - } + setPreviewResponse({ fields: [], error: null }); + setClusterData({ + documents: response ? response.rawResponse.hits.hits : [], + currentIdx: 0, + }); }, [indexPattern, search] ); const loadDocument = useCallback( async (id: string) => { - setPreviewResponse({ fields: [], error: null }); setIsFetchingDocument(true); const [response, error] = await search @@ -191,13 +195,13 @@ export const FieldPreviewProvider: FunctionComponent = ({ children }) => { setIsFetchingDocument(false); + let loadedDocuments: EsDocument[] = []; + if (response) { if (response.rawResponse.hits.total > 0) { - setDocuments(response.rawResponse.hits.hits); - setNavDocsIndex(0); + setPreviewResponse({ fields: [], error: null }); + loadedDocuments = response.rawResponse.hits.hits; } else { - setDocuments([]); - setNavDocsIndex(-1); setPreviewResponse({ fields: [], error: { @@ -226,10 +230,12 @@ export const FieldPreviewProvider: FunctionComponent = ({ children }) => { }, }, }); - } else { - setDocuments([]); - setNavDocsIndex(-1); } + + setClusterData({ + documents: loadedDocuments, + currentIdx: 0, + }); }, [indexPattern, search] ); @@ -239,7 +245,7 @@ export const FieldPreviewProvider: FunctionComponent = ({ children }) => { return; } - const currentApiCall = previewCount.current; + const currentApiCall = ++previewCount.current; setIsLoadingPreview(true); @@ -256,7 +262,6 @@ export const FieldPreviewProvider: FunctionComponent = ({ children }) => { return; } - previewCount.current = ++previewCount.current; setIsLoadingPreview(false); const { error: serverError } = response; @@ -301,36 +306,37 @@ export const FieldPreviewProvider: FunctionComponent = ({ children }) => { ]); const goToNextDoc = useCallback(() => { - if (navDocsIndex >= totalDocs - 1) { - setNavDocsIndex(0); + if (currentIdx >= totalDocs - 1) { + setClusterData((prev) => ({ ...prev, currentIdx: 0 })); + } else { + setClusterData((prev) => ({ ...prev, currentIdx: prev.currentIdx + 1 })); } - setNavDocsIndex((prev) => prev + 1); setIsLoadingPreview(true); - }, [navDocsIndex, totalDocs]); + }, [currentIdx, totalDocs]); const goToPrevDoc = useCallback(() => { - if (navDocsIndex === 0) { - setNavDocsIndex(totalDocs - 1); + if (currentIdx === 0) { + setClusterData((prev) => ({ ...prev, currentIdx: totalDocs - 1 })); + } else { + setClusterData((prev) => ({ ...prev, currentIdx: prev.currentIdx - 1 })); } - setNavDocsIndex((prev) => prev - 1); setIsLoadingPreview(true); - }, [navDocsIndex, totalDocs]); + }, [currentIdx, totalDocs]); const reset = useCallback(() => { // We increase the count of preview calls to discard any inflight // API call response coming in after calling reset() previewCount.current = ++previewCount.current; - setNavDocsIndex(0); + setClusterData({ + documents: [], + currentIdx: 0, + }); setFrom('cluster'); setPreviewResponse({ fields: [], error: null }); setIsLoadingPreview(false); setIsFetchingDocument(false); - - if (documents.length === 0) { - fetchSampleDocuments(); - } - }, [documents, fetchSampleDocuments]); + }, []); const ctx = useMemo( () => ({ @@ -349,8 +355,8 @@ export const FieldPreviewProvider: FunctionComponent = ({ children }) => { isLoading: isFetchingDocument, }, navigation: { - isFirstDoc: navDocsIndex === 0, - isLastDoc: navDocsIndex >= totalDocs - 1, + isFirstDoc: currentIdx === 0, + isLastDoc: currentIdx >= totalDocs - 1, next: goToNextDoc, prev: goToPrevDoc, }, @@ -373,7 +379,7 @@ export const FieldPreviewProvider: FunctionComponent = ({ children }) => { loadDocument, fetchSampleDocuments, isFetchingDocument, - navDocsIndex, + currentIdx, totalDocs, goToNextDoc, goToPrevDoc, @@ -397,10 +403,10 @@ export const FieldPreviewProvider: FunctionComponent = ({ children }) => { * field along with other document fields */ useEffect(() => { - if (fieldTypeToProcess === 'runtime') { + if (isPanelVisible && fieldTypeToProcess === 'runtime') { fetchSampleDocuments(); } - }, [fetchSampleDocuments, fieldTypeToProcess]); + }, [isPanelVisible, fetchSampleDocuments, fieldTypeToProcess]); /** * Each time the current document changes we update the parameters @@ -413,16 +419,27 @@ export const FieldPreviewProvider: FunctionComponent = ({ children }) => { }); }, [currentDocument, updateParams]); - /** If the script is null and we have a document we will preview the _source value */ useEffect(() => { if (name && document && script === null) { - setPreviewResponse({ - fields: [{ key: name, value: get(document, name) }], - error: null, - }); + // We have a field name, a document loaded but no script (the set value toggle is + // either turned off or we have a blank script). If we have a format then we'll + // preview the field with the format by reading the value from _source + if (format) { + setPreviewResponse({ + fields: [{ key: name, value: get(document, name) }], + error: null, + }); + } else { + // We don't have a format yet defined, we reset the preview. + // This will display the empty prompt. + setPreviewResponse({ + fields: [], + error: null, + }); + } setIsLoadingPreview(false); } - }, [name, document, script]); + }, [name, document, script, format]); return {children}; }; From 2d3e21e5741a3e8d6fff6c360f09dc8b370908ef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Se=CC=81bastien=20Loix?= Date: Fri, 18 Jun 2021 15:33:58 +0100 Subject: [PATCH 34/41] Discard _meta form field to detect if the form has been modified --- .../forms/docs/core/use_form_is_modified.mdx | 6 ++ .../forms/hook_form_lib/hooks/use_form.ts | 13 +++++ .../hooks/use_form_is_modified.test.tsx | 48 +++++++++++++--- .../hooks/use_form_is_modified.ts | 55 ++++++++++++++++--- .../static/forms/hook_form_lib/types.ts | 1 + .../components/field_editor/field_editor.tsx | 10 +++- 6 files changed, 118 insertions(+), 15 deletions(-) diff --git a/src/plugins/es_ui_shared/static/forms/docs/core/use_form_is_modified.mdx b/src/plugins/es_ui_shared/static/forms/docs/core/use_form_is_modified.mdx index bcf32ad59652e..06cfa1450a616 100644 --- a/src/plugins/es_ui_shared/static/forms/docs/core/use_form_is_modified.mdx +++ b/src/plugins/es_ui_shared/static/forms/docs/core/use_form_is_modified.mdx @@ -41,3 +41,9 @@ const ChildComponent = () => { ); }; ``` + +### discard + +**Type:** `string[]` + +If there are certain fields that you want to discard when checking if the form has been modified you can provide an array of field paths to the `discard` option. diff --git a/src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/use_form.ts b/src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/use_form.ts index fb334afb22b13..dadb0a2f4b78c 100644 --- a/src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/use_form.ts +++ b/src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/use_form.ts @@ -61,6 +61,7 @@ export function useForm( const [isValid, setIsValid] = useState(undefined); const fieldsRefs = useRef({}); + const fieldsRemovedRefs = useRef({}); const formUpdateSubscribers = useRef([]); const isMounted = useRef(false); const defaultValueDeserialized = useRef(defaultValueMemoized); @@ -213,6 +214,7 @@ export function useForm( (field) => { const fieldExists = fieldsRefs.current[field.path] !== undefined; fieldsRefs.current[field.path] = field; + delete fieldsRemovedRefs.current[field.path]; updateFormDataAt(field.path, field.value); @@ -235,6 +237,10 @@ export function useForm( const currentFormData = { ...getFormData$().value }; fieldNames.forEach((name) => { + // Keep a track of the fields that have been removed from the form + // This will allow us to know if the form has been modified + fieldsRemovedRefs.current[name] = fieldsRefs.current[name]; + delete fieldsRefs.current[name]; delete currentFormData[name]; }); @@ -271,6 +277,11 @@ export function useForm( [schema] ); + const getFieldsRemoved: FormHook['getFields'] = useCallback( + () => fieldsRemovedRefs.current, + [] + ); + // ---------------------------------- // -- Public API // ---------------------------------- @@ -441,6 +452,7 @@ export function useForm( __getFieldDefaultValue: getFieldDefaultValue, __addField: addField, __removeField: removeField, + __getFieldsRemoved: getFieldsRemoved, __validateFields: validateFields, }; }, [ @@ -453,6 +465,7 @@ export function useForm( setFieldValue, setFieldErrors, getFields, + getFieldsRemoved, getFormData, getErrors, getFieldDefaultValue, diff --git a/src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/use_form_is_modified.test.tsx b/src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/use_form_is_modified.test.tsx index c24ea1f69bf10..bb1f813b67a44 100644 --- a/src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/use_form_is_modified.test.tsx +++ b/src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/use_form_is_modified.test.tsx @@ -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 from 'react'; +import React, { useState } from 'react'; import { act } from 'react-dom/test-utils'; import { registerTestBed } from '../shared_imports'; @@ -15,20 +15,28 @@ import { Form } from '../components/form'; import { UseField } from '../components/use_field'; describe('useFormIsModified()', () => { - const TestComp = ({ - onIsModifiedChange, - }: { + interface Props { onIsModifiedChange: (isModified: boolean) => void; - }) => { + discard?: string[]; + } + + const TestComp = ({ onIsModifiedChange, discard = [] }: Props) => { const { form } = useForm(); - const isModified = useFormIsModified({ form }); + const isModified = useFormIsModified({ form, discard }); + const [isNameVisible, setIsNameVisible] = useState(true); // Call our jest.spy() with the latest hook value onIsModifiedChange(isModified); return ( - + {isNameVisible && ( + + )} + + ); }; @@ -59,4 +67,30 @@ describe('useFormIsModified()', () => { }); expect(lastValue()).toBe(false); }); + + test('should accepts a list of field to discard', async () => { + const { form } = await setup({ discard: ['toDiscard'] }); + + expect(lastValue()).toBe(false); + + await act(async () => { + form.setInputValue('toDiscardField', 'changed'); + }); + + // It should still not be modififed + expect(lastValue()).toBe(false); + }); + + test('should take into account if a field is removed from the DOM', async () => { + const { component, find } = await setup(); + + expect(lastValue()).toBe(false); + + await act(async () => { + find('hideNameButton').simulate('click'); + }); + component.update(); + + expect(lastValue()).toBe(true); + }); }); diff --git a/src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/use_form_is_modified.ts b/src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/use_form_is_modified.ts index 466a0753cf8b9..53a38b30a63e9 100644 --- a/src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/use_form_is_modified.ts +++ b/src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/use_form_is_modified.ts @@ -5,12 +5,15 @@ * in compliance with, at your election, the Elastic License 2.0 or the Server * Side Public License, v 1. */ -import { FormHook } from '../types'; +import { useMemo } from 'react'; +import { FieldHook, FormHook } from '../types'; import { useFormContext } from '../form_context'; import { useFormData } from './use_form_data'; interface Options { form?: FormHook; + /** List of field paths to discard when checking if a field has been modified */ + discard?: string[]; } /** @@ -23,7 +26,10 @@ interface Options { * @param options - Optional options object * @returns flag to indicate if the form has been modified */ -export const useFormIsModified = ({ form: formFromOptions }: Options = {}): boolean => { +export const useFormIsModified = ({ + form: formFromOptions, + discard = [], +}: Options = {}): boolean => { // As hook calls can not be conditional we first try to access the form through context let form = useFormContext({ throwIfNotFound: false }); @@ -37,13 +43,48 @@ export const useFormIsModified = ({ form: formFromOptions }: Options = {}): bool ); } - const { getFields } = form; + const { getFields, __getFieldsRemoved } = form; - // We listen to all the form data change to trigger re-render... + const discardToString = JSON.stringify(discard); + + // Create a map of the fields to discard to optimize look up + const fieldsToDiscard = useMemo(() => { + if (discard.length === 0) { + return; + } + + return discard.reduce((acc, path) => ({ ...acc, [path]: {} }), {} as { [key: string]: {} }); + + // discardToString === discard, we don't want to add it to the deps so we + // the coansumer does not need to memoize the array he provides. + }, [discardToString]); // eslint-disable-line react-hooks/exhaustive-deps + + // We listen to all the form data change to trigger a re-render + // and update our derived "isModified" state useFormData({ form }); - // ...and update our derived "isModified" state - const isModified = Object.values(getFields()).some((field) => field.isModified); + let predicate: (arg: [string, FieldHook]) => boolean = () => true; + + if (fieldsToDiscard) { + predicate = ([path]) => fieldsToDiscard[path] === undefined; + } + + const isModified = Object.entries(getFields()) + .filter(predicate) + .some(([_, field]) => field.isModified); + + if (isModified) { + return isModified; + } + + // Check if any field has been removed. + // If somme field has been removed then the form has been modified. + if (fieldsToDiscard) { + return ( + Object.keys(__getFieldsRemoved()).filter((path) => fieldsToDiscard[path] === undefined) + .length > 0 + ); + } - return isModified; + return Object.keys(__getFieldsRemoved()).length > 0; }; diff --git a/src/plugins/es_ui_shared/static/forms/hook_form_lib/types.ts b/src/plugins/es_ui_shared/static/forms/hook_form_lib/types.ts index c16ab2cf7f561..b778da8e365a3 100644 --- a/src/plugins/es_ui_shared/static/forms/hook_form_lib/types.ts +++ b/src/plugins/es_ui_shared/static/forms/hook_form_lib/types.ts @@ -59,6 +59,7 @@ export interface FormHook __updateDefaultValueAt: (field: string, value: unknown) => void; __readFieldConfigFromSchema: (field: string) => FieldConfig; __getFieldDefaultValue: (path: string) => unknown; + __getFieldsRemoved: () => FieldsMap; } export type FormSchema = { diff --git a/src/plugins/index_pattern_field_editor/public/components/field_editor/field_editor.tsx b/src/plugins/index_pattern_field_editor/public/components/field_editor/field_editor.tsx index f3898f2921d94..80b43953ade11 100644 --- a/src/plugins/index_pattern_field_editor/public/components/field_editor/field_editor.tsx +++ b/src/plugins/index_pattern_field_editor/public/components/field_editor/field_editor.tsx @@ -174,7 +174,15 @@ const FieldEditorComponent = ({ field, onChange, onFormModifiedChange, syntaxErr const i18nTexts = geti18nTexts(); const [formData] = useFormData({ form }); - const isFormModified = useFormIsModified({ form }); + const isFormModified = useFormIsModified({ + form, + discard: [ + '__meta__.isCustomLabelVisible', + '__meta__.isValueVisible', + '__meta__.isFormatVisible', + '__meta__.isPopularityVisible', + ], + }); const { name: updatedName, type: updatedType, script: updatedScript } = formData; const { name: nameField, type: typeField } = getFields(); From 2072ebeda2e5e2089468bc42bbd942ea87de5bad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Se=CC=81bastien=20Loix?= Date: Fri, 18 Jun 2021 19:16:18 +0100 Subject: [PATCH 35/41] Address CR changes --- .../confirm_modals/modified_field_modal.tsx | 12 ++--- .../components/preview/field_preview.tsx | 10 ++-- .../preview/field_preview_context.tsx | 53 +++++++++++-------- 3 files changed, 41 insertions(+), 34 deletions(-) diff --git a/src/plugins/index_pattern_field_editor/public/components/confirm_modals/modified_field_modal.tsx b/src/plugins/index_pattern_field_editor/public/components/confirm_modals/modified_field_modal.tsx index 9b9f01e7a0f6f..c9fabbaa73561 100644 --- a/src/plugins/index_pattern_field_editor/public/components/confirm_modals/modified_field_modal.tsx +++ b/src/plugins/index_pattern_field_editor/public/components/confirm_modals/modified_field_modal.tsx @@ -11,21 +11,15 @@ import { EuiConfirmModal } from '@elastic/eui'; const i18nTexts = { title: i18n.translate('indexPatternFieldEditor.cancelField.confirmationModal.title', { - defaultMessage: 'Unsaved changes', + defaultMessage: 'Discard changes', }), description: i18n.translate('indexPatternFieldEditor.cancelField.confirmationModal.description', { defaultMessage: `Changes that you've made to your field will be discarded, are you sure you want to continue?`, }), - confirmButton: i18n.translate( - 'indexPatternFieldEditor.cancelField.confirmationModal.yesButtonLabel', - { - defaultMessage: 'Yes', - } - ), cancelButton: i18n.translate( 'indexPatternFieldEditor.cancelField.confirmationModal.cancelButtonLabel', { - defaultMessage: 'No', + defaultMessage: 'Cancel', } ), }; @@ -41,7 +35,7 @@ export const ModifiedFieldModal: React.FC = ({ onCancel, onConfirm }) => title={i18nTexts.title} data-test-subj="runtimeFieldModifiedFieldConfirmModal" cancelButtonText={i18nTexts.cancelButton} - confirmButtonText={i18nTexts.confirmButton} + confirmButtonText={i18nTexts.title} onCancel={onCancel} onConfirm={onConfirm} maxWidth={600} diff --git a/src/plugins/index_pattern_field_editor/public/components/preview/field_preview.tsx b/src/plugins/index_pattern_field_editor/public/components/preview/field_preview.tsx index 267aeca903908..4f8596665dc1c 100644 --- a/src/plugins/index_pattern_field_editor/public/components/preview/field_preview.tsx +++ b/src/plugins/index_pattern_field_editor/public/components/preview/field_preview.tsx @@ -34,11 +34,13 @@ export const FieldPreview = () => { // 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 const isEmptyPromptVisible = - error !== null || fields.length > 0 - ? false - : previewCount === 0 + name === null && script === null && format === null ? true - : name === null || (script === null && format === null); + : // We have a response from the preview + error !== null || fields.length > 0 + ? false + : // We leave it on until we have at least called once the _execute API + previewCount === 0; const onFieldListResize = useCallback(({ height }: { height: number }) => { setFieldListHeight(height); diff --git a/src/plugins/index_pattern_field_editor/public/components/preview/field_preview_context.tsx b/src/plugins/index_pattern_field_editor/public/components/preview/field_preview_context.tsx index dd453b4ee6b21..f2eb1ffacce5c 100644 --- a/src/plugins/index_pattern_field_editor/public/components/preview/field_preview_context.tsx +++ b/src/plugins/index_pattern_field_editor/public/components/preview/field_preview_context.tsx @@ -125,11 +125,6 @@ export const FieldPreviewProvider: FunctionComponent = ({ children }) => { /** Define if we provide the document to preview from the cluster or from a custom JSON */ const [from, setFrom] = useState('cluster'); - const areAllParamsDefined = Object.entries(params) - // We don't need the "format" information for the _execute API - .filter(([key]) => key !== 'format') - .every(([_, value]) => Boolean(value)); - const { documents, currentIdx } = clusterData; const currentDocument: Record | undefined = useMemo(() => documents[currentIdx], [ documents, @@ -144,10 +139,18 @@ export const FieldPreviewProvider: FunctionComponent = ({ children }) => { setParams((prev) => ({ ...prev, ...updated })); }, []); + const allParamsDefined = useCallback( + () => + Object.entries(params) + // We don't need the "name" or "format" information for the _execute API + .filter(([key]) => key !== 'name' && key !== 'format') + .every(([_, value]) => Boolean(value)), + [params] + ); + const fetchSampleDocuments = useCallback( async (limit = 50) => { setIsFetchingDocument(true); - setIsLoadingPreview(true); const response = await search .search({ @@ -241,14 +244,12 @@ export const FieldPreviewProvider: FunctionComponent = ({ children }) => { ); const updatePreview = useCallback(async () => { - if (fieldTypeToProcess !== 'runtime' || !areAllParamsDefined) { + if (fieldTypeToProcess !== 'runtime' || !allParamsDefined()) { return; } const currentApiCall = ++previewCount.current; - setIsLoadingPreview(true); - const response = await getFieldPreview({ index: currentDocIndex, document: params.document!, @@ -298,7 +299,7 @@ export const FieldPreviewProvider: FunctionComponent = ({ children }) => { } }, [ fieldTypeToProcess, - areAllParamsDefined, + allParamsDefined, params, currentDocIndex, getFieldPreview, @@ -324,9 +325,9 @@ export const FieldPreviewProvider: FunctionComponent = ({ children }) => { }, [currentIdx, totalDocs]); const reset = useCallback(() => { - // We increase the count of preview calls to discard any inflight - // API call response coming in after calling reset() - previewCount.current = ++previewCount.current; + // By resetting the previewCount we will discard any inflight + // API call response coming in after calling reset() was called + previewCount.current = 0; setClusterData({ documents: [], @@ -397,6 +398,18 @@ export const FieldPreviewProvider: FunctionComponent = ({ children }) => { [updatePreview] ); + /** + * In order to immediately display the "Updating..." state indicator and not have to wait + * the 500ms of the debounce, we set the loading state in this effect + */ + useEffect(() => { + if (fieldTypeToProcess !== 'runtime' || !allParamsDefined()) { + return; + } + + setIsLoadingPreview(true); + }, [fieldTypeToProcess, allParamsDefined]); + /** * When the component mounts, if we are creating/editing a runtime field * we fetch sample documents from the cluster to be able to preview the runtime @@ -420,24 +433,22 @@ export const FieldPreviewProvider: FunctionComponent = ({ children }) => { }, [currentDocument, updateParams]); useEffect(() => { - if (name && document && script === null) { + if (document) { // We have a field name, a document loaded but no script (the set value toggle is // either turned off or we have a blank script). If we have a format then we'll // preview the field with the format by reading the value from _source - if (format) { + if (name && script === null) { setPreviewResponse({ fields: [{ key: name, value: get(document, name) }], error: null, }); } else { - // We don't have a format yet defined, we reset the preview. - // This will display the empty prompt. - setPreviewResponse({ - fields: [], + // We immediately update the field preview whenever the name changes + setPreviewResponse((prev) => ({ + fields: [{ key: name ?? '', value: prev.fields[0]?.value }], error: null, - }); + })); } - setIsLoadingPreview(false); } }, [name, document, script, format]); From ba0605acd9eed043e83c0bc80bc407af9967c801 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Se=CC=81bastien=20Loix?= Date: Fri, 18 Jun 2021 19:20:13 +0100 Subject: [PATCH 36/41] Fix functional test --- test/functional/apps/management/_field_formatter.js | 1 - 1 file changed, 1 deletion(-) diff --git a/test/functional/apps/management/_field_formatter.js b/test/functional/apps/management/_field_formatter.js index 383b4faecc40c..562ac3037b32b 100644 --- a/test/functional/apps/management/_field_formatter.js +++ b/test/functional/apps/management/_field_formatter.js @@ -45,7 +45,6 @@ export default function ({ getService, getPageObjects }) { await PageObjects.settings.setFieldFormat('duration'); await PageObjects.settings.setFieldFormat('bytes'); await PageObjects.settings.setFieldFormat('duration'); - await testSubjects.click('euiFlyoutCloseButton'); await PageObjects.settings.closeIndexPatternFieldEditor(); }); }); From ca735459d7afa4669f11f5e37b259cd9a68d4f29 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Se=CC=81bastien=20Loix?= Date: Mon, 21 Jun 2021 11:43:17 +0100 Subject: [PATCH 37/41] Improve logic to set form isModified for fields removed from the DOM --- .../forms/docs/core/use_form_is_modified.mdx | 2 + .../forms/hook_form_lib/hooks/use_form.ts | 7 ++ .../hooks/use_form_is_modified.test.tsx | 65 ++++++++++++++----- .../hooks/use_form_is_modified.ts | 27 +++++--- .../static/forms/hook_form_lib/types.ts | 1 + 5 files changed, 74 insertions(+), 28 deletions(-) diff --git a/src/plugins/es_ui_shared/static/forms/docs/core/use_form_is_modified.mdx b/src/plugins/es_ui_shared/static/forms/docs/core/use_form_is_modified.mdx index 06cfa1450a616..17f94b2921e63 100644 --- a/src/plugins/es_ui_shared/static/forms/docs/core/use_form_is_modified.mdx +++ b/src/plugins/es_ui_shared/static/forms/docs/core/use_form_is_modified.mdx @@ -13,6 +13,8 @@ There might be cases where you need to know if the form has been modified by the For that you can use the `useFormIsModified` hook which will update each time any of the field value changes. If the user makes a change and then undoes the change and puts the initial value back, the form **won't be marked** as modified. +**Important:** If you form dynamically adds and removes fields, the `isModified` state will be set to `true` when a field is removed from the DOM **only** if it was declared in the form initial `defaultValue` object. + ## Options ### form diff --git a/src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/use_form.ts b/src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/use_form.ts index dadb0a2f4b78c..8cfb34e59b92c 100644 --- a/src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/use_form.ts +++ b/src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/use_form.ts @@ -263,6 +263,11 @@ export function useForm( [getFormData$, updateFormData$, fieldsToArray] ); + const getFormDefaultValue: FormHook['__getFormDefaultValue'] = useCallback( + () => defaultValueDeserialized.current, + [] + ); + const getFieldDefaultValue: FormHook['__getFieldDefaultValue'] = useCallback( (fieldName) => get(defaultValueDeserialized.current, fieldName), [] @@ -449,6 +454,7 @@ export function useForm( __updateFormDataAt: updateFormDataAt, __updateDefaultValueAt: updateDefaultValueAt, __readFieldConfigFromSchema: readFieldConfigFromSchema, + __getFormDefaultValue: getFormDefaultValue, __getFieldDefaultValue: getFieldDefaultValue, __addField: addField, __removeField: removeField, @@ -468,6 +474,7 @@ export function useForm( getFieldsRemoved, getFormData, getErrors, + getFormDefaultValue, getFieldDefaultValue, reset, formOptions, diff --git a/src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/use_form_is_modified.test.tsx b/src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/use_form_is_modified.test.tsx index bb1f813b67a44..dc89cfe4f1fb6 100644 --- a/src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/use_form_is_modified.test.tsx +++ b/src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/use_form_is_modified.test.tsx @@ -20,29 +20,47 @@ describe('useFormIsModified()', () => { discard?: string[]; } + // We don't add the "lastName" field on purpose to test that we don't set the + // form "isModified" to true for fields that are not declared in the + // and that we remove from the DOM + const formDefaultValue = { + user: { + name: 'initialValue', + }, + toDiscard: 'initialValue', + }; + const TestComp = ({ onIsModifiedChange, discard = [] }: Props) => { - const { form } = useForm(); + const { form } = useForm({ defaultValue: formDefaultValue }); const isModified = useFormIsModified({ form, discard }); const [isNameVisible, setIsNameVisible] = useState(true); + const [isLastNameVisible, setIsLastNameVisible] = useState(true); // Call our jest.spy() with the latest hook value onIsModifiedChange(isModified); return (
- {isNameVisible && ( - - )} - - + ); }; const onIsModifiedChange = jest.fn(); - const lastValue = () => + const isFormModified = () => onIsModifiedChange.mock.calls[onIsModifiedChange.mock.calls.length - 1][0]; const setup = registerTestBed(TestComp, { @@ -53,44 +71,55 @@ describe('useFormIsModified()', () => { test('should return true **only** when the field value differs from its initial value', async () => { const { form } = await setup(); - expect(lastValue()).toBe(false); + expect(isFormModified()).toBe(false); await act(async () => { form.setInputValue('nameField', 'changed'); }); - expect(lastValue()).toBe(true); + expect(isFormModified()).toBe(true); // Put back to the initial value --> isModified should be false await act(async () => { form.setInputValue('nameField', 'initialValue'); }); - expect(lastValue()).toBe(false); + expect(isFormModified()).toBe(false); }); test('should accepts a list of field to discard', async () => { const { form } = await setup({ discard: ['toDiscard'] }); - expect(lastValue()).toBe(false); + expect(isFormModified()).toBe(false); await act(async () => { form.setInputValue('toDiscardField', 'changed'); }); // It should still not be modififed - expect(lastValue()).toBe(false); + expect(isFormModified()).toBe(false); }); - test('should take into account if a field is removed from the DOM', async () => { - const { component, find } = await setup(); + test('should take into account if a field is removed from the DOM **and** it existed on the form "defaultValue"', async () => { + const { find } = await setup(); - expect(lastValue()).toBe(false); + expect(isFormModified()).toBe(false); await act(async () => { find('hideNameButton').simulate('click'); }); - component.update(); + expect(isFormModified()).toBe(true); - expect(lastValue()).toBe(true); + // Put back the name + await act(async () => { + find('hideNameButton').simulate('click'); + }); + expect(isFormModified()).toBe(false); + + // Hide the lastname which is **not** in the form defaultValue + // this it won't set the form isModified to true + await act(async () => { + find('hideLastNameButton').simulate('click'); + }); + expect(isFormModified()).toBe(false); }); }); diff --git a/src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/use_form_is_modified.ts b/src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/use_form_is_modified.ts index 53a38b30a63e9..d87c44e614c04 100644 --- a/src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/use_form_is_modified.ts +++ b/src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/use_form_is_modified.ts @@ -6,6 +6,8 @@ * Side Public License, v 1. */ import { useMemo } from 'react'; +import { get } from 'lodash'; + import { FieldHook, FormHook } from '../types'; import { useFormContext } from '../form_context'; import { useFormData } from './use_form_data'; @@ -43,7 +45,7 @@ export const useFormIsModified = ({ ); } - const { getFields, __getFieldsRemoved } = form; + const { getFields, __getFieldsRemoved, __getFormDefaultValue } = form; const discardToString = JSON.stringify(discard); @@ -69,7 +71,7 @@ export const useFormIsModified = ({ predicate = ([path]) => fieldsToDiscard[path] === undefined; } - const isModified = Object.entries(getFields()) + let isModified = Object.entries(getFields()) .filter(predicate) .some(([_, field]) => field.isModified); @@ -78,13 +80,18 @@ export const useFormIsModified = ({ } // Check if any field has been removed. - // If somme field has been removed then the form has been modified. - if (fieldsToDiscard) { - return ( - Object.keys(__getFieldsRemoved()).filter((path) => fieldsToDiscard[path] === undefined) - .length > 0 - ); - } + // If somme field has been removed **and** they were originaly present on the + // form "defaultValue" then the form has been modified. + const formDefaultValue = __getFormDefaultValue(); + const fieldOnFormDefaultValue = (path: string) => Boolean(get(formDefaultValue, path)); + + const fieldsRemovedFromDOM: string[] = fieldsToDiscard + ? Object.keys(__getFieldsRemoved()) + .filter((path) => fieldsToDiscard[path] === undefined) + .filter(fieldOnFormDefaultValue) + : Object.keys(__getFieldsRemoved()).filter(fieldOnFormDefaultValue); + + isModified = fieldsRemovedFromDOM.length > 0; - return Object.keys(__getFieldsRemoved()).length > 0; + return isModified; }; diff --git a/src/plugins/es_ui_shared/static/forms/hook_form_lib/types.ts b/src/plugins/es_ui_shared/static/forms/hook_form_lib/types.ts index b778da8e365a3..7efbac1fefee6 100644 --- a/src/plugins/es_ui_shared/static/forms/hook_form_lib/types.ts +++ b/src/plugins/es_ui_shared/static/forms/hook_form_lib/types.ts @@ -58,6 +58,7 @@ export interface FormHook __updateFormDataAt: (field: string, value: unknown) => void; __updateDefaultValueAt: (field: string, value: unknown) => void; __readFieldConfigFromSchema: (field: string) => FieldConfig; + __getFormDefaultValue: () => FormData; __getFieldDefaultValue: (path: string) => unknown; __getFieldsRemoved: () => FieldsMap; } From 8f33eb0c9f47301dadfd3f3e18e9265e8eb2ab86 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Se=CC=81bastien=20Loix?= Date: Mon, 21 Jun 2021 13:26:49 +0100 Subject: [PATCH 38/41] Display confirm modal when clicking outside the flyout --- .../public/overlays/flyout/flyout_service.tsx | 13 +++++- .../field_editor_flyout_content.tsx | 44 ++++++++++++++----- .../field_editor_flyout_content_container.tsx | 8 +++- .../public/open_editor.tsx | 11 +++++ 4 files changed, 64 insertions(+), 12 deletions(-) diff --git a/src/core/public/overlays/flyout/flyout_service.tsx b/src/core/public/overlays/flyout/flyout_service.tsx index b41b85e5f429f..d8d625265aaa2 100644 --- a/src/core/public/overlays/flyout/flyout_service.tsx +++ b/src/core/public/overlays/flyout/flyout_service.tsx @@ -85,6 +85,7 @@ export interface OverlayFlyoutOpenOptions { size?: EuiFlyoutSize; maxWidth?: boolean | number | string; hideCloseButton?: boolean; + onClose?: () => void | boolean; } interface StartDeps { @@ -119,9 +120,19 @@ export class FlyoutService { this.activeFlyout = flyout; + const onCloseFlyout = () => { + if (options.onClose) { + const canClose = options.onClose(); + if (!canClose) { + return; + } + } + flyout.close(); + }; + render( - flyout.close()}> + , diff --git a/src/plugins/index_pattern_field_editor/public/components/field_editor_flyout_content.tsx b/src/plugins/index_pattern_field_editor/public/components/field_editor_flyout_content.tsx index 76f8c2e655fe6..f3756e0afac69 100644 --- a/src/plugins/index_pattern_field_editor/public/components/field_editor_flyout_content.tsx +++ b/src/plugins/index_pattern_field_editor/public/components/field_editor_flyout_content.tsx @@ -6,7 +6,7 @@ * Side Public License, v 1. */ -import React, { useState, useCallback, useMemo } from 'react'; +import React, { useState, useCallback, useMemo, useEffect } from 'react'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; import { @@ -60,6 +60,10 @@ export interface Props { /** Optional field to process */ field?: Field; isSavingField: boolean; + /** Handler to call when the component mounts. + * We will pass "up" data that the parent component might need + */ + onMounted?: (args: { canCloseValidator: () => boolean }) => void; } const FieldEditorFlyoutContentComponent = ({ @@ -68,6 +72,7 @@ const FieldEditorFlyoutContentComponent = ({ onCancel, runtimeFieldValidator, isSavingField, + onMounted, }: Props) => { const isEditingExistingField = !!field; const { indexPattern } = useFieldEditorContext(); @@ -104,6 +109,16 @@ const FieldEditorFlyoutContentComponent = ({ [painlessSyntaxError, clearSyntaxError] ); + const canCloseValidator = useCallback(() => { + if (isFormModified) { + setModalVisibility({ + ...defaultModalVisibility, + confirmUnsavedChanges: true, + }); + } + return !isFormModified; + }, [isFormModified]); + const onClickSave = useCallback(async () => { const { isValid, data } = await submit(); const nameChange = field?.name !== data.name; @@ -138,16 +153,12 @@ const FieldEditorFlyoutContentComponent = ({ }, [onSave, submit, runtimeFieldValidator, field, isEditingExistingField]); const onClickCancel = useCallback(() => { - if (isFormModified) { - setModalVisibility({ - ...defaultModalVisibility, - confirmUnsavedChanges: true, - }); - return; - } + const canClose = canCloseValidator(); - onCancel(); - }, [onCancel, isFormModified]); + if (canClose) { + onCancel(); + } + }, [onCancel, canCloseValidator]); const renderModal = () => { if (modalVisibility.confirmChangeNameOrType) { @@ -182,6 +193,19 @@ const FieldEditorFlyoutContentComponent = ({ return null; }; + useEffect(() => { + if (onMounted) { + // When the flyout mounts we send to the parent the validator to check + // if we can close the flyout or not (and display a confirm modal if needed). + // This is required to display the confirm modal when clicking outside the flyout. + onMounted({ canCloseValidator }); + + return () => { + onMounted({ canCloseValidator: () => true }); + }; + } + }, [onMounted, canCloseValidator]); + return ( <> void; /** Handler for the "cancel" footer button */ onCancel: () => void; + onMounted?: FieldEditorFlyoutContentProps['onMounted']; /** The docLinks start service from core */ docLinks: DocLinksStart; /** The index pattern where the field will be added */ @@ -61,6 +65,7 @@ export const FieldEditorFlyoutContentContainer = ({ field, onSave, onCancel, + onMounted, docLinks, fieldTypeToProcess, indexPattern, @@ -200,6 +205,7 @@ export const FieldEditorFlyoutContentContainer = ({ true, + }; + + const onMounted = (args: { canCloseValidator: () => boolean }) => { + canCloseValidator.current = args.canCloseValidator; + }; const openEditor = ({ onSave, @@ -103,6 +110,7 @@ export const getFieldEditorOpener = ({ { + return canCloseValidator.current(); + }, } ); From f7cf0edcaa26fd18ca4429a005ba569ed70fa472 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Se=CC=81bastien=20Loix?= Date: Mon, 21 Jun 2021 14:02:16 +0100 Subject: [PATCH 39/41] Add "onClose" public API --- ...ana-plugin-core-public.overlayflyoutopenoptions.md | 1 + ...in-core-public.overlayflyoutopenoptions.onclose.md | 11 +++++++++++ src/core/public/public.api.md | 2 ++ 3 files changed, 14 insertions(+) create mode 100644 docs/development/core/public/kibana-plugin-core-public.overlayflyoutopenoptions.onclose.md diff --git a/docs/development/core/public/kibana-plugin-core-public.overlayflyoutopenoptions.md b/docs/development/core/public/kibana-plugin-core-public.overlayflyoutopenoptions.md index fc4959b87a987..126ddda988e39 100644 --- a/docs/development/core/public/kibana-plugin-core-public.overlayflyoutopenoptions.md +++ b/docs/development/core/public/kibana-plugin-core-public.overlayflyoutopenoptions.md @@ -20,6 +20,7 @@ export interface OverlayFlyoutOpenOptions | [closeButtonAriaLabel](./kibana-plugin-core-public.overlayflyoutopenoptions.closebuttonarialabel.md) | string | | | [hideCloseButton](./kibana-plugin-core-public.overlayflyoutopenoptions.hideclosebutton.md) | boolean | | | [maxWidth](./kibana-plugin-core-public.overlayflyoutopenoptions.maxwidth.md) | boolean | number | string | | +| [onClose](./kibana-plugin-core-public.overlayflyoutopenoptions.onclose.md) | () => void | boolean | | | [ownFocus](./kibana-plugin-core-public.overlayflyoutopenoptions.ownfocus.md) | boolean | | | [size](./kibana-plugin-core-public.overlayflyoutopenoptions.size.md) | EuiFlyoutSize | | diff --git a/docs/development/core/public/kibana-plugin-core-public.overlayflyoutopenoptions.onclose.md b/docs/development/core/public/kibana-plugin-core-public.overlayflyoutopenoptions.onclose.md new file mode 100644 index 0000000000000..0aa247e48b176 --- /dev/null +++ b/docs/development/core/public/kibana-plugin-core-public.overlayflyoutopenoptions.onclose.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [OverlayFlyoutOpenOptions](./kibana-plugin-core-public.overlayflyoutopenoptions.md) > [onClose](./kibana-plugin-core-public.overlayflyoutopenoptions.onclose.md) + +## OverlayFlyoutOpenOptions.onClose property + +Signature: + +```typescript +onClose?: () => void | boolean; +``` diff --git a/src/core/public/public.api.md b/src/core/public/public.api.md index d411a90274590..04f6bd9b0edac 100644 --- a/src/core/public/public.api.md +++ b/src/core/public/public.api.md @@ -974,6 +974,8 @@ export interface OverlayFlyoutOpenOptions { // (undocumented) maxWidth?: boolean | number | string; // (undocumented) + onClose?: () => void | boolean; + // (undocumented) ownFocus?: boolean; // (undocumented) size?: EuiFlyoutSize; From 6b4b8d147797fc41f71a02dc5d1a64ccc5dbfe18 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Se=CC=81bastien=20Loix?= Date: Mon, 21 Jun 2021 14:30:27 +0100 Subject: [PATCH 40/41] Refactor overlay onClose option to provide the flyout instance --- ...bana-plugin-core-public.overlayflyoutopenoptions.md | 2 +- ...gin-core-public.overlayflyoutopenoptions.onclose.md | 2 +- src/core/public/overlays/flyout/flyout_service.tsx | 10 ++++------ src/core/public/public.api.md | 2 +- .../index_pattern_field_editor/public/open_editor.tsx | 7 +++++-- 5 files changed, 12 insertions(+), 11 deletions(-) diff --git a/docs/development/core/public/kibana-plugin-core-public.overlayflyoutopenoptions.md b/docs/development/core/public/kibana-plugin-core-public.overlayflyoutopenoptions.md index 126ddda988e39..bffcf8b26439c 100644 --- a/docs/development/core/public/kibana-plugin-core-public.overlayflyoutopenoptions.md +++ b/docs/development/core/public/kibana-plugin-core-public.overlayflyoutopenoptions.md @@ -20,7 +20,7 @@ export interface OverlayFlyoutOpenOptions | [closeButtonAriaLabel](./kibana-plugin-core-public.overlayflyoutopenoptions.closebuttonarialabel.md) | string | | | [hideCloseButton](./kibana-plugin-core-public.overlayflyoutopenoptions.hideclosebutton.md) | boolean | | | [maxWidth](./kibana-plugin-core-public.overlayflyoutopenoptions.maxwidth.md) | boolean | number | string | | -| [onClose](./kibana-plugin-core-public.overlayflyoutopenoptions.onclose.md) | () => void | boolean | | +| [onClose](./kibana-plugin-core-public.overlayflyoutopenoptions.onclose.md) | (flyout: OverlayRef) => void | | | [ownFocus](./kibana-plugin-core-public.overlayflyoutopenoptions.ownfocus.md) | boolean | | | [size](./kibana-plugin-core-public.overlayflyoutopenoptions.size.md) | EuiFlyoutSize | | diff --git a/docs/development/core/public/kibana-plugin-core-public.overlayflyoutopenoptions.onclose.md b/docs/development/core/public/kibana-plugin-core-public.overlayflyoutopenoptions.onclose.md index 0aa247e48b176..83903adbf2428 100644 --- a/docs/development/core/public/kibana-plugin-core-public.overlayflyoutopenoptions.onclose.md +++ b/docs/development/core/public/kibana-plugin-core-public.overlayflyoutopenoptions.onclose.md @@ -7,5 +7,5 @@ Signature: ```typescript -onClose?: () => void | boolean; +onClose?: (flyout: OverlayRef) => void; ``` diff --git a/src/core/public/overlays/flyout/flyout_service.tsx b/src/core/public/overlays/flyout/flyout_service.tsx index d8d625265aaa2..4fb4f7fc7f052 100644 --- a/src/core/public/overlays/flyout/flyout_service.tsx +++ b/src/core/public/overlays/flyout/flyout_service.tsx @@ -85,7 +85,7 @@ export interface OverlayFlyoutOpenOptions { size?: EuiFlyoutSize; maxWidth?: boolean | number | string; hideCloseButton?: boolean; - onClose?: () => void | boolean; + onClose?: (flyout: OverlayRef) => void; } interface StartDeps { @@ -122,12 +122,10 @@ export class FlyoutService { const onCloseFlyout = () => { if (options.onClose) { - const canClose = options.onClose(); - if (!canClose) { - return; - } + options.onClose(flyout); + } else { + flyout.close(); } - flyout.close(); }; render( diff --git a/src/core/public/public.api.md b/src/core/public/public.api.md index 04f6bd9b0edac..fd5a1d6d2448d 100644 --- a/src/core/public/public.api.md +++ b/src/core/public/public.api.md @@ -974,7 +974,7 @@ export interface OverlayFlyoutOpenOptions { // (undocumented) maxWidth?: boolean | number | string; // (undocumented) - onClose?: () => void | boolean; + onClose?: (flyout: OverlayRef) => void; // (undocumented) ownFocus?: boolean; // (undocumented) diff --git a/src/plugins/index_pattern_field_editor/public/open_editor.tsx b/src/plugins/index_pattern_field_editor/public/open_editor.tsx index 7a877b4648b57..d6e661812280e 100644 --- a/src/plugins/index_pattern_field_editor/public/open_editor.tsx +++ b/src/plugins/index_pattern_field_editor/public/open_editor.tsx @@ -132,8 +132,11 @@ export const getFieldEditorOpener = ({ size: 'l', ownFocus: true, hideCloseButton: true, - onClose: () => { - return canCloseValidator.current(); + onClose: (flyout) => { + const canClose = canCloseValidator.current(); + if (canClose) { + flyout.close(); + } }, } ); From eea8a9a491534e959acaad62f5919cde2f617302 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Se=CC=81bastien=20Loix?= Date: Mon, 21 Jun 2021 15:12:33 +0100 Subject: [PATCH 41/41] Add doc for overlay onClose option --- .../kibana-plugin-core-public.overlayflyoutopenoptions.md | 2 +- ...ana-plugin-core-public.overlayflyoutopenoptions.onclose.md | 2 ++ src/core/public/overlays/flyout/flyout_service.tsx | 4 ++++ src/core/public/public.api.md | 1 - 4 files changed, 7 insertions(+), 2 deletions(-) diff --git a/docs/development/core/public/kibana-plugin-core-public.overlayflyoutopenoptions.md b/docs/development/core/public/kibana-plugin-core-public.overlayflyoutopenoptions.md index bffcf8b26439c..337e3da5a8951 100644 --- a/docs/development/core/public/kibana-plugin-core-public.overlayflyoutopenoptions.md +++ b/docs/development/core/public/kibana-plugin-core-public.overlayflyoutopenoptions.md @@ -20,7 +20,7 @@ export interface OverlayFlyoutOpenOptions | [closeButtonAriaLabel](./kibana-plugin-core-public.overlayflyoutopenoptions.closebuttonarialabel.md) | string | | | [hideCloseButton](./kibana-plugin-core-public.overlayflyoutopenoptions.hideclosebutton.md) | boolean | | | [maxWidth](./kibana-plugin-core-public.overlayflyoutopenoptions.maxwidth.md) | boolean | number | string | | -| [onClose](./kibana-plugin-core-public.overlayflyoutopenoptions.onclose.md) | (flyout: OverlayRef) => void | | +| [onClose](./kibana-plugin-core-public.overlayflyoutopenoptions.onclose.md) | (flyout: OverlayRef) => void | EuiFlyout onClose handler. If provided the consumer is responsible for calling flyout.close() to close the flyout; | | [ownFocus](./kibana-plugin-core-public.overlayflyoutopenoptions.ownfocus.md) | boolean | | | [size](./kibana-plugin-core-public.overlayflyoutopenoptions.size.md) | EuiFlyoutSize | | diff --git a/docs/development/core/public/kibana-plugin-core-public.overlayflyoutopenoptions.onclose.md b/docs/development/core/public/kibana-plugin-core-public.overlayflyoutopenoptions.onclose.md index 83903adbf2428..5cfbba4c84a36 100644 --- a/docs/development/core/public/kibana-plugin-core-public.overlayflyoutopenoptions.onclose.md +++ b/docs/development/core/public/kibana-plugin-core-public.overlayflyoutopenoptions.onclose.md @@ -4,6 +4,8 @@ ## OverlayFlyoutOpenOptions.onClose property +EuiFlyout onClose handler. If provided the consumer is responsible for calling flyout.close() to close the flyout; + Signature: ```typescript diff --git a/src/core/public/overlays/flyout/flyout_service.tsx b/src/core/public/overlays/flyout/flyout_service.tsx index 4fb4f7fc7f052..68ec2aa21f005 100644 --- a/src/core/public/overlays/flyout/flyout_service.tsx +++ b/src/core/public/overlays/flyout/flyout_service.tsx @@ -85,6 +85,10 @@ export interface OverlayFlyoutOpenOptions { size?: EuiFlyoutSize; maxWidth?: boolean | number | string; hideCloseButton?: boolean; + /** + * EuiFlyout onClose handler. + * If provided the consumer is responsible for calling flyout.close() to close the flyout; + */ onClose?: (flyout: OverlayRef) => void; } diff --git a/src/core/public/public.api.md b/src/core/public/public.api.md index fd5a1d6d2448d..1b4b1b73d2c09 100644 --- a/src/core/public/public.api.md +++ b/src/core/public/public.api.md @@ -973,7 +973,6 @@ export interface OverlayFlyoutOpenOptions { hideCloseButton?: boolean; // (undocumented) maxWidth?: boolean | number | string; - // (undocumented) onClose?: (flyout: OverlayRef) => void; // (undocumented) ownFocus?: boolean;