diff --git a/api_docs/lens.json b/api_docs/lens.json index 1c7581a8a1db..235f2021e982 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 d15445f3e10a..29945e15874b 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 000000000000..5cd1f2c4f620 --- /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 07d5ef950d21..0dd7f20debcb 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 d473d728dc36..a5c19911f60b 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 715d15e99ec2..bfb1106f5080 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 03f281e90f6b..fef8ee171830 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 6405309870f0..4e86725d5100 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 ac82d9d3c436..dcc11ea42611 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 3b4940263c4b..3094b6463fe1 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 0f6cf6b980ac..01ba0726d9e4 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 249212657565..74ea13a81539 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 0a05a351fb14..a556c6ce0c09 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 bc4bb028696b..b8dc7edc81bb 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 64da5e4fb9f7..1f571ac6744a 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 92b0e27c3d1a..04f137a6a021 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 f45f963ee174..79155184a5f6 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 c667ddea06b3..60d2c7199cb9 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 dfddccbf2039..134f0b4185b8 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 31b7b665fb2f..38ba1f698ecc 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 000000000000..9b8ef3a8b690 --- /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 48bede9754c5..2022b19b1464 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'); + }, }); }