From 90e301344955124fff2744427821d0d55dad34b3 Mon Sep 17 00:00:00 2001 From: Joe Reuter Date: Thu, 18 Mar 2021 08:18:59 +0100 Subject: [PATCH 01/15] add mocks for Lens plugin (#94593) Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- x-pack/plugins/lens/public/mocks.ts | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 x-pack/plugins/lens/public/mocks.ts diff --git a/x-pack/plugins/lens/public/mocks.ts b/x-pack/plugins/lens/public/mocks.ts new file mode 100644 index 0000000000000..10d3be1d1b57d --- /dev/null +++ b/x-pack/plugins/lens/public/mocks.ts @@ -0,0 +1,23 @@ +/* + * 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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { LensPublicStart } from '.'; + +export type Start = jest.Mocked; + +const createStartContract = (): Start => { + const startContract: Start = { + EmbeddableComponent: jest.fn(() => null), + canUseEditor: jest.fn(() => true), + navigateToPrefilledEditor: jest.fn(), + }; + return startContract; +}; + +export const lensPluginMock = { + createStartContract, +}; From 52a1ce17233079138ee4cf6bd9f86eaa6266ea7a Mon Sep 17 00:00:00 2001 From: Joe Reuter Date: Thu, 18 Mar 2021 08:21:39 +0100 Subject: [PATCH 02/15] [Lens] Runtime field editor (#91882) --- api_docs/lens.json | 14 +- .../field_editor/form_fields/script_field.tsx | 5 +- test/functional/services/field_editor.ts | 44 +++++ test/functional/services/index.ts | 2 + x-pack/plugins/lens/kibana.json | 3 +- .../indexpattern_datasource/datapanel.scss | 4 + .../datapanel.test.tsx | 82 +++++++- .../indexpattern_datasource/datapanel.tsx | 181 ++++++++++++++++-- .../field_item.test.tsx | 22 ++- .../indexpattern_datasource/field_item.tsx | 34 ++++ .../indexpattern_datasource/field_list.tsx | 4 + .../fields_accordion.tsx | 13 +- .../public/indexpattern_datasource/index.ts | 5 +- .../indexpattern.test.ts | 2 + .../indexpattern_datasource/indexpattern.tsx | 5 + .../public/indexpattern_datasource/loader.ts | 1 + .../public/indexpattern_datasource/types.ts | 1 + x-pack/plugins/lens/public/plugin.ts | 2 + x-pack/plugins/lens/tsconfig.json | 1 + x-pack/test/functional/apps/lens/index.ts | 1 + .../functional/apps/lens/runtime_fields.ts | 66 +++++++ .../test/functional/page_objects/lens_page.ts | 32 ++++ 22 files changed, 488 insertions(+), 36 deletions(-) create mode 100644 test/functional/services/field_editor.ts create mode 100644 x-pack/test/functional/apps/lens/runtime_fields.ts diff --git a/api_docs/lens.json b/api_docs/lens.json index 1c7581a8a1db6..235f2021e9823 100644 --- a/api_docs/lens.json +++ b/api_docs/lens.json @@ -330,7 +330,7 @@ "description": [], "source": { "path": "x-pack/plugins/lens/public/indexpattern_datasource/types.ts", - "lineNumber": 72 + "lineNumber": 73 }, "signature": [ "Record boolean" @@ -542,7 +542,7 @@ ], "source": { "path": "x-pack/plugins/lens/public/plugin.ts", - "lineNumber": 79 + "lineNumber": 81 }, "initialIsOpen": false }, @@ -1553,7 +1553,7 @@ "description": [], "source": { "path": "x-pack/plugins/lens/public/indexpattern_datasource/types.ts", - "lineNumber": 75 + "lineNumber": 76 }, "signature": [ "{ columns: Record; columnOrder: string[]; incompleteColumns?: Record | undefined; }" diff --git a/src/plugins/index_pattern_field_editor/public/components/field_editor/form_fields/script_field.tsx b/src/plugins/index_pattern_field_editor/public/components/field_editor/form_fields/script_field.tsx index d15445f3e10ae..29945e15874b7 100644 --- a/src/plugins/index_pattern_field_editor/public/components/field_editor/form_fields/script_field.tsx +++ b/src/plugins/index_pattern_field_editor/public/components/field_editor/form_fields/script_field.tsx @@ -91,7 +91,10 @@ export const ScriptField = React.memo(({ existingConcreteFields, links, syntaxEr const painlessSyntaxErrors = PainlessLang.getSyntaxErrors(); // It is possible for there to be more than one editor in a view, // so we need to get the syntax errors based on the editor (aka model) ID - const editorHasSyntaxErrors = editorId && painlessSyntaxErrors[editorId].length > 0; + const editorHasSyntaxErrors = + editorId && + painlessSyntaxErrors[editorId] && + painlessSyntaxErrors[editorId].length > 0; if (editorHasSyntaxErrors) { return resolve({ diff --git a/test/functional/services/field_editor.ts b/test/functional/services/field_editor.ts new file mode 100644 index 0000000000000..5cd1f2c4f6202 --- /dev/null +++ b/test/functional/services/field_editor.ts @@ -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 { FtrProviderContext } from '../ftr_provider_context'; + +export function FieldEditorProvider({ getService }: FtrProviderContext) { + const browser = getService('browser'); + const retry = getService('retry'); + const testSubjects = getService('testSubjects'); + + class FieldEditor { + public async setName(name: string) { + await testSubjects.setValue('nameField > input', name); + } + public async enableValue() { + await testSubjects.setEuiSwitch('valueRow > toggle', 'check'); + } + public async disableValue() { + await testSubjects.setEuiSwitch('valueRow > toggle', 'uncheck'); + } + public async typeScript(script: string) { + const editor = await (await testSubjects.find('valueRow')).findByClassName( + 'react-monaco-editor-container' + ); + const textarea = await editor.findByClassName('monaco-mouse-cursor-text'); + + await textarea.click(); + await browser.pressKeys(script); + } + public async save() { + await retry.try(async () => { + await testSubjects.click('fieldSaveButton'); + await testSubjects.missingOrFail('fieldSaveButton', { timeout: 2000 }); + }); + } + } + + return new FieldEditor(); +} diff --git a/test/functional/services/index.ts b/test/functional/services/index.ts index 07d5ef950d21e..0dd7f20debcbd 100644 --- a/test/functional/services/index.ts +++ b/test/functional/services/index.ts @@ -31,6 +31,7 @@ import { FilterBarProvider } from './filter_bar'; import { FlyoutProvider } from './flyout'; import { GlobalNavProvider } from './global_nav'; import { InspectorProvider } from './inspector'; +import { FieldEditorProvider } from './field_editor'; import { ManagementMenuProvider } from './management'; import { QueryBarProvider } from './query_bar'; import { RemoteProvider } from './remote'; @@ -74,6 +75,7 @@ export const services = { browser: BrowserProvider, pieChart: PieChartProvider, inspector: InspectorProvider, + fieldEditor: FieldEditorProvider, vegaDebugInspector: VegaDebugInspectorViewProvider, appsMenu: AppsMenuProvider, globalNav: GlobalNavProvider, diff --git a/x-pack/plugins/lens/kibana.json b/x-pack/plugins/lens/kibana.json index d473d728dc361..a5c19911f60b9 100644 --- a/x-pack/plugins/lens/kibana.json +++ b/x-pack/plugins/lens/kibana.json @@ -15,7 +15,8 @@ "uiActions", "embeddable", "share", - "presentationUtil" + "presentationUtil", + "indexPatternFieldEditor" ], "optionalPlugins": [ "usageCollection", diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/datapanel.scss b/x-pack/plugins/lens/public/indexpattern_datasource/datapanel.scss index 715d15e99ec20..bfb1106f5080e 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/datapanel.scss +++ b/x-pack/plugins/lens/public/indexpattern_datasource/datapanel.scss @@ -4,6 +4,10 @@ padding: $euiSize $euiSize 0; } +.lnsInnerIndexPatternDataPanel__switcher { + min-width: 0; +} + .lnsInnerIndexPatternDataPanel__header { display: flex; align-items: center; diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/datapanel.test.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/datapanel.test.tsx index 03f281e90f6b5..fef8ee171830d 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/datapanel.test.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/datapanel.test.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import React, { ChangeEvent } from 'react'; +import React, { ChangeEvent, ReactElement } from 'react'; import { createMockedDragDropContext } from './mocks'; import { dataPluginMock } from '../../../../../src/plugins/data/public/mocks'; import { InnerIndexPatternDataPanel, IndexPatternDataPanel, MemoizedDataPanel } from './datapanel'; @@ -19,6 +19,7 @@ import { ChangeIndexPattern } from './change_indexpattern'; import { EuiProgress, EuiLoadingSpinner } from '@elastic/eui'; import { documentField } from './document_field'; import { chartPluginMock } from '../../../../../src/plugins/charts/public/mocks'; +import { indexPatternFieldEditorPluginMock } from '../../../../../src/plugins/index_pattern_field_editor/public/mocks'; import { getFieldByNameFactory } from './pure_helpers'; const fieldsOne = [ @@ -240,14 +241,16 @@ describe('IndexPattern Data Panel', () => { let defaultProps: Parameters[0] & { showNoDataPopover: () => void; }; - let core: ReturnType; + let core: ReturnType; beforeEach(() => { - core = coreMock.createSetup(); + core = coreMock.createStart(); defaultProps = { indexPatternRefs: [], existingFields: {}, data: dataPluginMock.createStartContract(), + indexPatternFieldEditor: indexPatternFieldEditorPluginMock.createStartContract(), + onUpdateIndexPattern: jest.fn(), dragDropContext: createMockedDragDropContext(), currentIndexPatternId: '1', indexPatterns: initialState.indexPatterns, @@ -806,5 +809,78 @@ describe('IndexPattern Data Panel', () => { 'memory', ]); }); + describe('edit field list', () => { + beforeEach(() => { + props.indexPatternFieldEditor.userPermissions.editIndexPattern = () => true; + }); + it('should call field editor plugin on clicking add button', async () => { + const mockIndexPattern = {}; + (props.data.indexPatterns.get as jest.Mock).mockImplementation(() => + Promise.resolve(mockIndexPattern) + ); + const wrapper = mountWithIntl(); + act(() => { + (wrapper + .find('[data-test-subj="lnsIndexPatternActions-popover"]') + .first() + .prop('children') as ReactElement).props.items[0].props.onClick(); + }); + + // wait for indx pattern to be loaded + await new Promise((r) => setTimeout(r, 0)); + + expect(props.indexPatternFieldEditor.openEditor).toHaveBeenCalledWith( + expect.objectContaining({ + ctx: expect.objectContaining({ + indexPattern: mockIndexPattern, + }), + }) + ); + }); + + it('should reload index pattern if callback gets called', async () => { + const mockIndexPattern = { + id: '1', + fields: [ + { + name: 'fieldOne', + aggregatable: true, + }, + ], + metaFields: [], + }; + (props.data.indexPatterns.get as jest.Mock).mockImplementation(() => + Promise.resolve(mockIndexPattern) + ); + const wrapper = mountWithIntl(); + act(() => { + (wrapper + .find('[data-test-subj="lnsIndexPatternActions-popover"]') + .first() + .prop('children') as ReactElement).props.items[0].props.onClick(); + }); + // wait for indx pattern to be loaded + await new Promise((r) => setTimeout(r, 0)); + await (props.indexPatternFieldEditor.openEditor as jest.Mock).mock.calls[0][0].onSave(); + // wait for indx pattern to be loaded + await new Promise((r) => setTimeout(r, 0)); + expect(props.onUpdateIndexPattern).toHaveBeenCalledWith( + expect.objectContaining({ + fields: [ + expect.objectContaining({ + name: 'fieldOne', + }), + expect.anything(), + ], + }) + ); + }); + + it('should not render add button without permissions', () => { + props.indexPatternFieldEditor.userPermissions.editIndexPattern = () => false; + const wrapper = mountWithIntl(); + expect(wrapper.find('[data-test-subj="indexPattern-add-field"]').exists()).toBe(false); + }); + }); }); }); diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/datapanel.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/datapanel.tsx index 6405309870f0c..4e86725d5100c 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/datapanel.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/datapanel.tsx @@ -7,7 +7,7 @@ import './datapanel.scss'; import { uniq, groupBy } from 'lodash'; -import React, { useState, memo, useCallback, useMemo } from 'react'; +import React, { useState, memo, useCallback, useMemo, useRef, useEffect } from 'react'; import { EuiFlexGroup, EuiFlexItem, @@ -20,9 +20,11 @@ import { EuiFilterGroup, EuiFilterButton, EuiScreenReaderOnly, + EuiButtonIcon, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; +import { CoreStart } from 'kibana/public'; import { DataPublicPluginStart, EsQueryConfig, Query, Filter } from 'src/plugins/data/public'; import { htmlIdGenerator } from '@elastic/eui'; import { DatasourceDataPanelProps, DataType, StateSetter } from '../types'; @@ -34,12 +36,13 @@ import { IndexPatternRef, } from './types'; import { trackUiEvent } from '../lens_ui_telemetry'; -import { syncExistingFields } from './loader'; +import { loadIndexPatterns, syncExistingFields } from './loader'; import { fieldExists } from './pure_helpers'; import { Loader } from '../loader'; import { esQuery, IIndexPattern } from '../../../../../src/plugins/data/public'; +import { IndexPatternFieldEditorStart } from '../../../../../src/plugins/index_pattern_field_editor/public'; -export type Props = DatasourceDataPanelProps & { +export type Props = Omit, 'core'> & { data: DataPublicPluginStart; changeIndexPattern: ( id: string, @@ -47,6 +50,8 @@ export type Props = DatasourceDataPanelProps & { setState: StateSetter ) => void; charts: ChartsPluginSetup; + core: CoreStart; + indexPatternFieldEditor: IndexPatternFieldEditorStart; }; import { LensFieldIcon } from './lens_field_icon'; import { ChangeIndexPattern } from './change_indexpattern'; @@ -112,6 +117,7 @@ export function IndexPatternDataPanel({ dateRange, changeIndexPattern, charts, + indexPatternFieldEditor, showNoDataPopover, dropOntoWorkspace, hasSuggestionForField, @@ -122,6 +128,19 @@ export function IndexPatternDataPanel({ [state, setState, changeIndexPattern] ); + const onUpdateIndexPattern = useCallback( + (indexPattern: IndexPattern) => { + setState((prevState) => ({ + ...prevState, + indexPatterns: { + ...prevState.indexPatterns, + [indexPattern.id]: indexPattern, + }, + })); + }, + [setState] + ); + const indexPatternList = uniq( Object.values(state.layers) .map((l) => l.indexPatternId) @@ -165,6 +184,7 @@ export function IndexPatternDataPanel({ dateRange.fromDate, dateRange.toDate, indexPatternList.map((x) => `${x.title}:${x.timeFieldName}`).join(','), + state.indexPatterns, ]} /> @@ -205,7 +225,9 @@ export function IndexPatternDataPanel({ core={core} data={data} charts={charts} + indexPatternFieldEditor={indexPatternFieldEditor} onChangeIndexPattern={onChangeIndexPattern} + onUpdateIndexPattern={onUpdateIndexPattern} existingFields={state.existingFields} existenceFetchFailed={state.existenceFetchFailed} dropOntoWorkspace={dropOntoWorkspace} @@ -254,21 +276,26 @@ export const InnerIndexPatternDataPanel = function InnerIndexPatternDataPanel({ filters, dragDropContext, onChangeIndexPattern, + onUpdateIndexPattern, core, data, + indexPatternFieldEditor, existingFields, charts, dropOntoWorkspace, hasSuggestionForField, -}: Omit & { +}: Omit & { data: DataPublicPluginStart; + core: CoreStart; currentIndexPatternId: string; indexPatternRefs: IndexPatternRef[]; indexPatterns: Record; dragDropContext: DragContextState; onChangeIndexPattern: (newId: string) => void; + onUpdateIndexPattern: (indexPattern: IndexPattern) => void; existingFields: IndexPatternPrivateState['existingFields']; charts: ChartsPluginSetup; + indexPatternFieldEditor: IndexPatternFieldEditorStart; existenceFetchFailed?: boolean; }) { const [localState, setLocalState] = useState({ @@ -289,6 +316,8 @@ export const InnerIndexPatternDataPanel = function InnerIndexPatternDataPanel({ const fieldInfoUnavailable = existenceFetchFailed || currentIndexPattern.hasRestrictions; + const editPermission = indexPatternFieldEditor.userPermissions.editIndexPattern(); + const unfilteredFieldGroups: FieldGroups = useMemo(() => { const containsData = (field: IndexPatternField) => { const overallField = currentIndexPattern.getFieldByName(field.name); @@ -456,6 +485,48 @@ export const InnerIndexPatternDataPanel = function InnerIndexPatternDataPanel({ [nameFilter, typeFilter] ); + const closeFieldEditor = useRef<() => void | undefined>(); + + useEffect(() => { + return () => { + // Make sure to close the editor when unmounting + if (closeFieldEditor.current) { + closeFieldEditor.current(); + } + }; + }, []); + + const editField = useMemo( + () => + editPermission + ? async (fieldName?: string, uiAction: 'edit' | 'add' = 'edit') => { + trackUiEvent(`open_field_editor_${uiAction}`); + const indexPatternInstance = await data.indexPatterns.get(currentIndexPattern.id); + closeFieldEditor.current = indexPatternFieldEditor.openEditor({ + ctx: { + indexPattern: indexPatternInstance, + }, + fieldName, + onSave: async () => { + trackUiEvent(`save_field_${uiAction}`); + const newlyMappedIndexPattern = await loadIndexPatterns({ + indexPatternsService: data.indexPatterns, + cache: {}, + patterns: [currentIndexPattern.id], + }); + onUpdateIndexPattern(newlyMappedIndexPattern[currentIndexPattern.id]); + }, + }); + } + : undefined, + [data, indexPatternFieldEditor, currentIndexPattern, editPermission, onUpdateIndexPattern] + ); + + const addField = useMemo( + () => (editPermission && editField ? () => editField(undefined, 'add') : undefined), + [editField, editPermission] + ); + const fieldProps = useMemo( () => ({ core, @@ -479,6 +550,8 @@ export const InnerIndexPatternDataPanel = function InnerIndexPatternDataPanel({ ] ); + const [popoverOpen, setPopoverOpen] = useState(false); + return ( -
- { - onChangeIndexPattern(newId); - clearLocalState(); - }} - /> -
+ + + { + onChangeIndexPattern(newId); + clearLocalState(); + }} + /> + + {addField && ( + + { + setPopoverOpen(false); + }} + ownFocus + data-test-subj="lnsIndexPatternActions-popover" + button={ + { + setPopoverOpen(!popoverOpen); + }} + /> + } + > + { + setPopoverOpen(false); + addField(); + }} + > + {i18n.translate('xpack.lens.indexPatterns.addFieldButton', { + defaultMessage: 'Add field to index pattern', + })} + , + { + setPopoverOpen(false); + core.application.navigateToApp('management', { + path: `/kibana/indexPatterns/patterns/${currentIndexPattern.id}`, + }); + }} + > + {i18n.translate('xpack.lens.indexPatterns.manageFieldButton', { + defaultMessage: 'Manage index pattern fields', + })} + , + ]} + /> + + + )} +
diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/field_item.test.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/field_item.test.tsx index ac82d9d3c4363..dcc11ea426117 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/field_item.test.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/field_item.test.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import React from 'react'; +import React, { MouseEvent, ReactElement } from 'react'; import { ReactWrapper } from 'enzyme'; import { act } from 'react-dom/test-utils'; import { EuiLoadingSpinner, EuiPopover } from '@elastic/eui'; @@ -125,6 +125,26 @@ describe('IndexPattern Field Item', () => { ); }); + it('should render edit field button if callback is set', () => { + core.http.post.mockImplementation(() => { + return new Promise(() => {}); + }); + const editFieldSpy = jest.fn(); + const wrapper = mountWithIntl( + + ); + clickField(wrapper, 'bytes'); + wrapper.update(); + const popoverContent = wrapper.find(EuiPopover).prop('children'); + act(() => { + mountWithIntl(popoverContent as ReactElement) + .find('[data-test-subj="lnsFieldListPanelEdit"]') + .first() + .prop('onClick')!({} as MouseEvent); + }); + expect(editFieldSpy).toHaveBeenCalledWith('bytes'); + }); + it('should request field stats every time the button is clicked', async () => { let resolveFunction: (arg: unknown) => void; diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/field_item.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/field_item.tsx index 3b4940263c4bd..3094b6463fe15 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/field_item.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/field_item.tsx @@ -72,6 +72,7 @@ export interface FieldItemProps { itemIndex: number; groupIndex: number; dropOntoWorkspace: DatasourceDataPanelProps['dropOntoWorkspace']; + editField?: (name: string) => void; hasSuggestionForField: DatasourceDataPanelProps['hasSuggestionForField']; } @@ -105,10 +106,22 @@ export const InnerFieldItem = function InnerFieldItem(props: FieldItemProps) { itemIndex, groupIndex, dropOntoWorkspace, + editField, } = props; const [infoIsOpen, setOpen] = useState(false); + const closeAndEdit = useMemo( + () => + editField + ? (name: string) => { + editField(name); + setOpen(false); + } + : undefined, + [editField, setOpen] + ); + const dropOntoWorkspaceAndClose = useCallback( (droppedField: DragDropIdentifier) => { dropOntoWorkspace(droppedField); @@ -256,6 +269,7 @@ export const InnerFieldItem = function InnerFieldItem(props: FieldItemProps) { @@ -270,11 +284,13 @@ function FieldPanelHeader({ field, hasSuggestionForField, dropOntoWorkspace, + editField, }: { field: IndexPatternField; indexPatternId: string; hasSuggestionForField: DatasourceDataPanelProps['hasSuggestionForField']; dropOntoWorkspace: DatasourceDataPanelProps['dropOntoWorkspace']; + editField?: (name: string) => void; }) { const draggableField = { indexPatternId, @@ -298,6 +314,22 @@ function FieldPanelHeader({ dropOntoWorkspace={dropOntoWorkspace} field={draggableField} /> + {editField && ( + + editField(field.name)} + iconType="pencil" + data-test-subj="lnsFieldListPanelEdit" + aria-label={i18n.translate('xpack.lens.indexPattern.editFieldLabel', { + defaultMessage: 'Edit index pattern field', + })} + /> + + )} ); } @@ -314,6 +346,7 @@ function FieldItemPopoverContents(props: State & FieldItemProps) { chartsThemeService, data: { fieldFormats }, dropOntoWorkspace, + editField, hasSuggestionForField, hideDetails, } = props; @@ -345,6 +378,7 @@ function FieldItemPopoverContents(props: State & FieldItemProps) { field={field} dropOntoWorkspace={dropOntoWorkspace} hasSuggestionForField={hasSuggestionForField} + editField={editField} /> ); diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/field_list.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/field_list.tsx index 0f6cf6b980ac7..01ba0726d9e4d 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/field_list.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/field_list.tsx @@ -52,6 +52,7 @@ export const FieldList = React.memo(function FieldList({ existFieldsInIndex, dropOntoWorkspace, hasSuggestionForField, + editField, }: { exists: (field: IndexPatternField) => boolean; fieldGroups: FieldGroups; @@ -66,6 +67,7 @@ export const FieldList = React.memo(function FieldList({ existFieldsInIndex: boolean; dropOntoWorkspace: DatasourceDataPanelProps['dropOntoWorkspace']; hasSuggestionForField: DatasourceDataPanelProps['hasSuggestionForField']; + editField?: (name: string) => void; }) { const [pageSize, setPageSize] = useState(PAGINATION_SIZE); const [scrollContainer, setScrollContainer] = useState(undefined); @@ -141,6 +143,7 @@ export const FieldList = React.memo(function FieldList({ {...fieldProps} exists={exists(field)} field={field} + editField={editField} hideDetails={true} key={field.name} itemIndex={index} @@ -165,6 +168,7 @@ export const FieldList = React.memo(function FieldList({ label={fieldGroup.title} helpTooltip={fieldGroup.helpText} exists={exists} + editField={editField} hideDetails={fieldGroup.hideDetails} hasLoaded={!!hasSyncedExistingFields} fieldsCount={fieldGroup.fields.length} diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/fields_accordion.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/fields_accordion.tsx index 249212657565d..74ea13a81539f 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/fields_accordion.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/fields_accordion.tsx @@ -54,6 +54,7 @@ export interface FieldsAccordionProps { groupIndex: number; dropOntoWorkspace: DatasourceDataPanelProps['dropOntoWorkspace']; hasSuggestionForField: DatasourceDataPanelProps['hasSuggestionForField']; + editField?: (name: string) => void; } export const FieldsAccordion = memo(function InnerFieldsAccordion({ @@ -74,6 +75,7 @@ export const FieldsAccordion = memo(function InnerFieldsAccordion({ groupIndex, dropOntoWorkspace, hasSuggestionForField, + editField, }: FieldsAccordionProps) { const renderField = useCallback( (field: IndexPatternField, index) => ( @@ -87,9 +89,18 @@ export const FieldsAccordion = memo(function InnerFieldsAccordion({ groupIndex={groupIndex} dropOntoWorkspace={dropOntoWorkspace} hasSuggestionForField={hasSuggestionForField} + editField={editField} /> ), - [fieldProps, exists, hideDetails, dropOntoWorkspace, hasSuggestionForField, groupIndex] + [ + fieldProps, + exists, + hideDetails, + dropOntoWorkspace, + hasSuggestionForField, + groupIndex, + editField, + ] ); const renderButton = useMemo(() => { diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/index.ts b/x-pack/plugins/lens/public/indexpattern_datasource/index.ts index 0a05a351fb14a..a556c6ce0c095 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/index.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/index.ts @@ -9,6 +9,7 @@ import { CoreSetup } from 'kibana/public'; import { Storage } from '../../../../../src/plugins/kibana_utils/public'; import { ExpressionsSetup } from '../../../../../src/plugins/expressions/public'; import { ChartsPluginSetup } from '../../../../../src/plugins/charts/public'; +import { IndexPatternFieldEditorStart } from '../../../../../src/plugins/index_pattern_field_editor/public'; import { DataPublicPluginSetup, DataPublicPluginStart, @@ -24,6 +25,7 @@ export interface IndexPatternDatasourceSetupPlugins { export interface IndexPatternDatasourceStartPlugins { data: DataPublicPluginStart; + indexPatternFieldEditor: IndexPatternFieldEditorStart; } export class IndexPatternDatasource { @@ -42,7 +44,7 @@ export class IndexPatternDatasource { getTimeScaleFunction, getSuffixFormatter, } = await import('../async_services'); - return core.getStartServices().then(([coreStart, { data }]) => { + return core.getStartServices().then(([coreStart, { data, indexPatternFieldEditor }]) => { data.fieldFormats.register([getSuffixFormatter(data.fieldFormats.deserialize)]); expressions.registerFunction(getTimeScaleFunction(data)); expressions.registerFunction(counterRate); @@ -53,6 +55,7 @@ export class IndexPatternDatasource { storage: new Storage(localStorage), data, charts, + indexPatternFieldEditor, }); }) as Promise; }); diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.test.ts b/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.test.ts index bc4bb028696b8..b8dc7edc81bb2 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.test.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.test.ts @@ -16,6 +16,7 @@ import { chartPluginMock } from '../../../../../src/plugins/charts/public/mocks' import { getFieldByNameFactory } from './pure_helpers'; import { operationDefinitionMap, getErrorMessages } from './operations'; import { createMockedReferenceOperation } from './operations/mocks'; +import { indexPatternFieldEditorPluginMock } from 'src/plugins/index_pattern_field_editor/public/mocks'; jest.mock('./loader'); jest.mock('../id_generator'); @@ -170,6 +171,7 @@ describe('IndexPattern Data Source', () => { core: coreMock.createStart(), data: dataPluginMock.createStartContract(), charts: chartPluginMock.createSetupContract(), + indexPatternFieldEditor: indexPatternFieldEditorPluginMock.createStartContract(), }); baseState = { diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.tsx index 64da5e4fb9f74..1f571ac6744a9 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.tsx @@ -12,6 +12,7 @@ import { I18nProvider } from '@kbn/i18n/react'; import { CoreStart, SavedObjectReference } from 'kibana/public'; import { i18n } from '@kbn/i18n'; import { IStorageWrapper } from 'src/plugins/kibana_utils/public'; +import { IndexPatternFieldEditorStart } from '../../../../../src/plugins/index_pattern_field_editor/public'; import { DatasourceDimensionEditorProps, DatasourceDimensionTriggerProps, @@ -76,11 +77,13 @@ export function getIndexPatternDatasource({ storage, data, charts, + indexPatternFieldEditor, }: { core: CoreStart; storage: IStorageWrapper; data: DataPublicPluginStart; charts: ChartsPluginSetup; + indexPatternFieldEditor: IndexPatternFieldEditorStart; }) { const uiSettings = core.uiSettings; const onIndexPatternLoadError = (err: Error) => @@ -191,7 +194,9 @@ export function getIndexPatternDatasource({ changeIndexPattern={handleChangeIndexPattern} data={data} charts={charts} + indexPatternFieldEditor={indexPatternFieldEditor} {...props} + core={core} /> , domElement diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/loader.ts b/x-pack/plugins/lens/public/indexpattern_datasource/loader.ts index 92b0e27c3d1a7..04f137a6a0211 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/loader.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/loader.ts @@ -68,6 +68,7 @@ export async function loadIndexPatterns({ meta: indexPattern.metaFields.includes(field.name), esTypes: field.esTypes, scripted: field.scripted, + runtime: Boolean(field.runtimeField), }; // Simplifies tests by hiding optional properties instead of undefined diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/types.ts b/x-pack/plugins/lens/public/indexpattern_datasource/types.ts index f45f963ee174f..79155184a5f6d 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/types.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/types.ts @@ -57,6 +57,7 @@ export type IndexPatternField = IFieldType & { displayName: string; aggregationRestrictions?: Partial; meta?: boolean; + runtime?: boolean; }; export interface IndexPatternLayer { diff --git a/x-pack/plugins/lens/public/plugin.ts b/x-pack/plugins/lens/public/plugin.ts index c667ddea06b33..60d2c7199cb9b 100644 --- a/x-pack/plugins/lens/public/plugin.ts +++ b/x-pack/plugins/lens/public/plugin.ts @@ -21,6 +21,7 @@ import { ChartsPluginSetup, ChartsPluginStart } from '../../../../src/plugins/ch import { PresentationUtilPluginStart } from '../../../../src/plugins/presentation_util/public'; import { EmbeddableStateTransfer } from '../../../../src/plugins/embeddable/public'; import { EditorFrameService } from './editor_frame_service'; +import { IndexPatternFieldEditorStart } from '../../../../src/plugins/index_pattern_field_editor/public'; import { IndexPatternDatasource, IndexPatternDatasourceSetupPlugins, @@ -74,6 +75,7 @@ export interface LensPluginStartDependencies { charts: ChartsPluginStart; savedObjectsTagging?: SavedObjectTaggingPluginStart; presentationUtil: PresentationUtilPluginStart; + indexPatternFieldEditor: IndexPatternFieldEditorStart; } export interface LensPublicStart { diff --git a/x-pack/plugins/lens/tsconfig.json b/x-pack/plugins/lens/tsconfig.json index dfddccbf20392..134f0b4185b84 100644 --- a/x-pack/plugins/lens/tsconfig.json +++ b/x-pack/plugins/lens/tsconfig.json @@ -21,6 +21,7 @@ { "path": "../global_search/tsconfig.json"}, { "path": "../saved_objects_tagging/tsconfig.json"}, { "path": "../../../src/plugins/data/tsconfig.json"}, + { "path": "../../../src/plugins/index_pattern_field_editor/tsconfig.json"}, { "path": "../../../src/plugins/charts/tsconfig.json"}, { "path": "../../../src/plugins/expressions/tsconfig.json"}, { "path": "../../../src/plugins/navigation/tsconfig.json" }, diff --git a/x-pack/test/functional/apps/lens/index.ts b/x-pack/test/functional/apps/lens/index.ts index 31b7b665fb2f0..38ba1f698ecce 100644 --- a/x-pack/test/functional/apps/lens/index.ts +++ b/x-pack/test/functional/apps/lens/index.ts @@ -31,6 +31,7 @@ export default function ({ getService, loadTestFile }: FtrProviderContext) { loadTestFile(require.resolve('./smokescreen')); loadTestFile(require.resolve('./add_to_dashboard')); loadTestFile(require.resolve('./table')); + loadTestFile(require.resolve('./runtime_fields')); loadTestFile(require.resolve('./dashboard')); loadTestFile(require.resolve('./persistent_context')); loadTestFile(require.resolve('./colors')); diff --git a/x-pack/test/functional/apps/lens/runtime_fields.ts b/x-pack/test/functional/apps/lens/runtime_fields.ts new file mode 100644 index 0000000000000..9b8ef3a8b6905 --- /dev/null +++ b/x-pack/test/functional/apps/lens/runtime_fields.ts @@ -0,0 +1,66 @@ +/* + * 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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import expect from '@kbn/expect'; +import { FtrProviderContext } from '../../ftr_provider_context'; + +export default function ({ getService, getPageObjects }: FtrProviderContext) { + const PageObjects = getPageObjects(['visualize', 'lens', 'common', 'header']); + const filterBar = getService('filterBar'); + const fieldEditor = getService('fieldEditor'); + const retry = getService('retry'); + + describe('lens runtime fields', () => { + it('should be able to add runtime field and use it', async () => { + await PageObjects.visualize.navigateToNewVisualization(); + await PageObjects.visualize.clickVisType('lens'); + await PageObjects.lens.goToTimeRange(); + await PageObjects.lens.switchToVisualization('lnsDatatable'); + await PageObjects.lens.clickAddField(); + await fieldEditor.setName('runtimefield'); + await fieldEditor.enableValue(); + await fieldEditor.typeScript("emit('abc')"); + await fieldEditor.save(); + await PageObjects.lens.searchField('runtime'); + await PageObjects.lens.waitForField('runtimefield'); + await PageObjects.lens.dragFieldToWorkspace('runtimefield'); + await PageObjects.lens.waitForVisualization(); + expect(await PageObjects.lens.getDatatableHeaderText(0)).to.equal( + 'Top values of runtimefield' + ); + expect(await PageObjects.lens.getDatatableCellText(0, 0)).to.eql('abc'); + }); + + it('should able to filter runtime fields', async () => { + await retry.try(async () => { + await PageObjects.lens.clickTableCellAction(0, 0, 'lensDatatableFilterOut'); + await PageObjects.lens.waitForVisualization(); + expect(await PageObjects.lens.isShowingNoResults()).to.equal(true); + }); + await filterBar.removeAllFilters(); + await PageObjects.lens.waitForVisualization(); + }); + + it('should able to edit field', async () => { + await PageObjects.lens.clickField('runtimefield'); + await PageObjects.lens.editField(); + await fieldEditor.setName('runtimefield2'); + await fieldEditor.save(); + await PageObjects.lens.searchField('runtime'); + await PageObjects.lens.waitForField('runtimefield2'); + await PageObjects.lens.dragFieldToDimensionTrigger( + 'runtimefield2', + 'lnsDatatable_rows > lns-dimensionTrigger' + ); + await PageObjects.lens.waitForVisualization(); + expect(await PageObjects.lens.getDatatableHeaderText(0)).to.equal( + 'Top values of runtimefield2' + ); + expect(await PageObjects.lens.getDatatableCellText(0, 0)).to.eql('abc'); + }); + }); +} diff --git a/x-pack/test/functional/page_objects/lens_page.ts b/x-pack/test/functional/page_objects/lens_page.ts index 48bede9754c58..2022b19b14644 100644 --- a/x-pack/test/functional/page_objects/lens_page.ts +++ b/x-pack/test/functional/page_objects/lens_page.ts @@ -172,6 +172,32 @@ export function LensPageProvider({ getService, getPageObjects }: FtrProviderCont await PageObjects.header.waitUntilLoadingHasFinished(); }, + /** + * Drags field to workspace + * + * @param field - the desired field for the dimension + * */ + async clickField(field: string) { + await testSubjects.click(`lnsFieldListPanelField-${field}`); + }, + + async editField() { + await retry.try(async () => { + await testSubjects.click('lnsFieldListPanelEdit'); + await testSubjects.missingOrFail('lnsFieldListPanelEdit'); + }); + }, + + async searchField(name: string) { + await testSubjects.setValue('lnsIndexPatternFieldSearch', name); + }, + + async waitForField(field: string) { + await retry.try(async () => { + await testSubjects.existOrFail(`lnsFieldListPanelField-${field}`); + }); + }, + /** * Copies field to chosen destination that is defined by distance of `steps` * (right arrow presses) from it @@ -772,5 +798,11 @@ export function LensPageProvider({ getService, getPageObjects }: FtrProviderCont return firstCount === secondCount; }); }, + + async clickAddField() { + await testSubjects.click('lnsIndexPatternActions'); + await testSubjects.existOrFail('indexPattern-add-field'); + await testSubjects.click('indexPattern-add-field'); + }, }); } From 628bb4b3775aba5fee822e76fc8152ccc6866087 Mon Sep 17 00:00:00 2001 From: Jean-Louis Leysens Date: Thu, 18 Mar 2021 10:07:39 +0100 Subject: [PATCH 03/15] [Ingest Pipelines] Fix serialization and deserialization of user input for "patterns" fields (#94689) * updated serialization and deserialization behavior of dissect and gsub processors, also addded a test * also fix grok processor * pivot input checking to use JSON.stringify and JSON.parse Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../drag_and_drop_text_list.tsx | 20 +++++-- .../processor_form/processors/dissect.tsx | 7 ++- .../processor_form/processors/grok.tsx | 14 +++-- .../processor_form/processors/gsub.tsx | 9 +++- .../processor_form/processors/shared.test.ts | 53 +++++++++++++++++++ .../processor_form/processors/shared.ts | 51 +++++++++++++++++- 6 files changed, 142 insertions(+), 12 deletions(-) create mode 100644 x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processors/shared.test.ts diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/field_components/drag_and_drop_text_list.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/field_components/drag_and_drop_text_list.tsx index abe4eb0fa5916..03bdc2ceb9579 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/field_components/drag_and_drop_text_list.tsx +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/field_components/drag_and_drop_text_list.tsx @@ -44,7 +44,15 @@ interface Props { /** * Validation to be applied to every text item */ - textValidation?: ValidationFunc; + textValidations?: Array>; + /** + * Serializer to be applied to every text item + */ + textSerializer?: (v: string) => O; + /** + * Deserializer to be applied to every text item + */ + textDeserializer?: (v: unknown) => string; } const i18nTexts = { @@ -63,7 +71,9 @@ function DragAndDropTextListComponent({ onAdd, onRemove, addLabel, - textValidation, + textValidations, + textDeserializer, + textSerializer, }: Props): JSX.Element { const [droppableId] = useState(() => uuid.v4()); const [firstItemId] = useState(() => uuid.v4()); @@ -133,9 +143,11 @@ function DragAndDropTextListComponent({ path={item.path} config={{ - validations: textValidation - ? [{ validator: textValidation }] + validations: textValidations + ? textValidations.map((validator) => ({ validator })) : undefined, + deserializer: textDeserializer, + serializer: textSerializer, }} readDefaultValueOnForm={!item.isNew} > diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processors/dissect.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processors/dissect.tsx index 6652ad277cc26..3864581317e38 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processors/dissect.tsx +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processors/dissect.tsx @@ -22,7 +22,7 @@ import { import { FieldNameField } from './common_fields/field_name_field'; import { IgnoreMissingField } from './common_fields/ignore_missing_field'; -import { EDITOR_PX_HEIGHT, from } from './shared'; +import { EDITOR_PX_HEIGHT, from, to, isJSONStringValidator } from './shared'; const { emptyField } = fieldValidators; @@ -34,6 +34,8 @@ const getFieldsConfig = (esDocUrl: string): Record => { label: i18n.translate('xpack.ingestPipelines.pipelineEditor.dissectForm.patternFieldLabel', { defaultMessage: 'Pattern', }), + deserializer: to.escapeBackslashes, + serializer: from.unescapeBackslashes, helpText: ( => { ) ), }, + { + validator: isJSONStringValidator, + }, ], }, /* Optional field config */ diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processors/grok.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processors/grok.tsx index f15441ea1f92b..ae2d341c58c30 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processors/grok.tsx +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processors/grok.tsx @@ -5,6 +5,7 @@ * 2.0. */ +import { flow } from 'lodash'; import React, { FunctionComponent } from 'react'; import { i18n } from '@kbn/i18n'; @@ -22,7 +23,7 @@ import { XJsonEditor, DragAndDropTextList } from '../field_components'; import { FieldNameField } from './common_fields/field_name_field'; import { IgnoreMissingField } from './common_fields/ignore_missing_field'; -import { FieldsConfig, to, from, EDITOR_PX_HEIGHT } from './shared'; +import { FieldsConfig, to, from, EDITOR_PX_HEIGHT, isJSONStringValidator } from './shared'; const { isJsonField, emptyField } = fieldValidators; @@ -46,7 +47,10 @@ const patternsValidation: ValidationFunc = ({ value, f } }; -const patternValidation = emptyField(valueRequiredMessage); +const patternValidations: Array> = [ + emptyField(valueRequiredMessage), + isJSONStringValidator, +]; const fieldsConfig: FieldsConfig = { /* Required field configs */ @@ -54,6 +58,8 @@ const fieldsConfig: FieldsConfig = { label: i18n.translate('xpack.ingestPipelines.pipelineEditor.grokForm.patternsFieldLabel', { defaultMessage: 'Patterns', }), + deserializer: flow(String, to.escapeBackslashes), + serializer: from.unescapeBackslashes, helpText: i18n.translate('xpack.ingestPipelines.pipelineEditor.grokForm.patternsHelpText', { defaultMessage: 'Grok expressions used to match and extract named capture groups. Uses the first matching expression.', @@ -133,7 +139,9 @@ export const Grok: FunctionComponent = () => { onAdd={addItem} onRemove={removeItem} addLabel={i18nTexts.addPatternLabel} - textValidation={patternValidation} + textValidations={patternValidations} + textDeserializer={fieldsConfig.patterns?.deserializer} + textSerializer={fieldsConfig.patterns?.serializer} /> ); }} diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processors/gsub.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processors/gsub.tsx index edfa59ea80281..11d06f3cca6fb 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processors/gsub.tsx +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processors/gsub.tsx @@ -5,6 +5,7 @@ * 2.0. */ +import { flow } from 'lodash'; import React, { FunctionComponent } from 'react'; import { i18n } from '@kbn/i18n'; @@ -12,7 +13,7 @@ import { FIELD_TYPES, fieldValidators, UseField, Field } from '../../../../../.. import { TextEditor } from '../field_components'; -import { EDITOR_PX_HEIGHT, FieldsConfig } from './shared'; +import { EDITOR_PX_HEIGHT, FieldsConfig, from, to, isJSONStringValidator } from './shared'; import { FieldNameField } from './common_fields/field_name_field'; import { IgnoreMissingField } from './common_fields/ignore_missing_field'; import { TargetField } from './common_fields/target_field'; @@ -26,7 +27,8 @@ const fieldsConfig: FieldsConfig = { label: i18n.translate('xpack.ingestPipelines.pipelineEditor.gsubForm.patternFieldLabel', { defaultMessage: 'Pattern', }), - deserializer: String, + deserializer: flow(String, to.escapeBackslashes), + serializer: from.unescapeBackslashes, helpText: i18n.translate('xpack.ingestPipelines.pipelineEditor.gsubForm.patternFieldHelpText', { defaultMessage: 'Regular expression used to match substrings in the field.', }), @@ -38,6 +40,9 @@ const fieldsConfig: FieldsConfig = { }) ), }, + { + validator: isJSONStringValidator, + }, ], }, diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processors/shared.test.ts b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processors/shared.test.ts new file mode 100644 index 0000000000000..4b01f22a9383d --- /dev/null +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processors/shared.test.ts @@ -0,0 +1,53 @@ +/* + * 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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { from, to } from './shared'; + +describe('shared', () => { + describe('deserialization helpers', () => { + // This is the text that will be passed to the text input + test('to.escapeBackslashes', () => { + // this input loaded from the server + const input1 = 'my\ttab'; + expect(to.escapeBackslashes(input1)).toBe('my\\ttab'); + + // this input loaded from the server + const input2 = 'my\\ttab'; + expect(to.escapeBackslashes(input2)).toBe('my\\\\ttab'); + + // this input loaded from the server + const input3 = '\t\n\rOK'; + expect(to.escapeBackslashes(input3)).toBe('\\t\\n\\rOK'); + + const input4 = `%{clientip} %{ident} %{auth} [%{@timestamp}] \"%{verb} %{request} HTTP/%{httpversion}\" %{status} %{size}`; + expect(to.escapeBackslashes(input4)).toBe( + '%{clientip} %{ident} %{auth} [%{@timestamp}] \\"%{verb} %{request} HTTP/%{httpversion}\\" %{status} %{size}' + ); + }); + }); + + describe('serialization helpers', () => { + test('from.unescapeBackslashes', () => { + // user typed in "my\ttab" + const input1 = 'my\\ttab'; + expect(from.unescapeBackslashes(input1)).toBe('my\ttab'); + + // user typed in "my\\tab" + const input2 = 'my\\\\ttab'; + expect(from.unescapeBackslashes(input2)).toBe('my\\ttab'); + + // user typed in "\t\n\rOK" + const input3 = '\\t\\n\\rOK'; + expect(from.unescapeBackslashes(input3)).toBe('\t\n\rOK'); + + const input5 = `%{clientip} %{ident} %{auth} [%{@timestamp}] \\"%{verb} %{request} HTTP/%{httpversion}\\" %{status} %{size}`; + expect(from.unescapeBackslashes(input5)).toBe( + `%{clientip} %{ident} %{auth} [%{@timestamp}] \"%{verb} %{request} HTTP/%{httpversion}\" %{status} %{size}` + ); + }); + }); +}); diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processors/shared.ts b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processors/shared.ts index 399da3c05c783..bafba412c767f 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processors/shared.ts +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processors/shared.ts @@ -5,11 +5,12 @@ * 2.0. */ -import { FunctionComponent } from 'react'; +import type { FunctionComponent } from 'react'; import * as rt from 'io-ts'; +import { i18n } from '@kbn/i18n'; import { isRight } from 'fp-ts/lib/Either'; -import { FieldConfig } from '../../../../../../shared_imports'; +import { FieldConfig, ValidationFunc } from '../../../../../../shared_imports'; export const arrayOfStrings = rt.array(rt.string); @@ -36,6 +37,17 @@ export const to = { arrayOfStrings: (v: unknown): string[] => isArrayOfStrings(v) ? v : typeof v === 'string' && v.length ? [v] : [], jsonString: (v: unknown) => (v ? JSON.stringify(v, null, 2) : '{}'), + /** + * Useful when deserializing strings that will be rendered inside of text areas or text inputs. We want + * a string like: "my\ttab" to render the same, not to render as "mytab". + */ + escapeBackslashes: (v: unknown) => { + if (typeof v === 'string') { + const s = JSON.stringify(v); + return s.slice(1, s.length - 1); + } + return v; + }, }; /** @@ -69,6 +81,41 @@ export const from = { optionalArrayOfStrings: (v: string[]) => (v.length ? v : undefined), undefinedIfValue: (value: unknown) => (v: boolean) => (v === value ? undefined : v), emptyStringToUndefined: (v: unknown) => (v === '' ? undefined : v), + /** + * Useful when serializing user input from a