diff --git a/x-pack/platform/plugins/shared/index_management/__jest__/client_integration/index_details_page/select_inference_id.helpers.tsx b/x-pack/platform/plugins/shared/index_management/__jest__/client_integration/index_details_page/select_inference_id.helpers.tsx deleted file mode 100644 index bd5c38a7ce2e6..0000000000000 --- a/x-pack/platform/plugins/shared/index_management/__jest__/client_integration/index_details_page/select_inference_id.helpers.tsx +++ /dev/null @@ -1,132 +0,0 @@ -/* - * 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 React from 'react'; -import { render } from '@testing-library/react'; -import type { InferenceAPIConfigResponse } from '@kbn/ml-trained-models-utils'; - -import { - Form, - useForm, -} from '../../../public/application/components/mappings_editor/shared_imports'; -import type { SelectInferenceIdProps } from '../../../public/application/components/mappings_editor/components/document_fields/field_parameters/select_inference_id'; -import { SelectInferenceId } from '../../../public/application/components/mappings_editor/components/document_fields/field_parameters/select_inference_id'; -import { useLoadInferenceEndpoints } from '../../../public/application/services/api'; - -export const INFERENCE_LOCATOR = 'SEARCH_INFERENCE_ENDPOINTS'; - -export const createMockLocator = () => ({ - useUrl: jest.fn().mockReturnValue('https://redirect.me/to/inference_endpoints'), -}); - -export const mockResendRequest = jest.fn(); - -export const DEFAULT_ENDPOINTS: InferenceAPIConfigResponse[] = [ - { - inference_id: '.jina-embeddings-v5-text-small', - task_type: 'text_embedding', - service: 'jina', - service_settings: { model_id: 'jina-embeddings-v5-text-small' }, - }, - { - inference_id: '.preconfigured-elser', - task_type: 'sparse_embedding', - service: 'elastic', - service_settings: { model_id: 'elser' }, - }, - { - inference_id: '.preconfigured-e5', - task_type: 'text_embedding', - service: 'elastic', - service_settings: { model_id: 'e5' }, - }, - { - inference_id: 'endpoint-1', - task_type: 'text_embedding', - service: 'openai', - service_settings: { model_id: 'text-embedding-3-large' }, - }, - { - inference_id: 'endpoint-2', - task_type: 'sparse_embedding', - service: 'elastic', - service_settings: { model_id: 'elser' }, - }, -] as InferenceAPIConfigResponse[]; - -export const defaultProps: SelectInferenceIdProps = { - 'data-test-subj': 'data-inference-endpoint-list', -}; - -export function TestFormWrapper({ - children, - initialValue = '.preconfigured-elser', -}: { - children: React.ReactElement; - initialValue?: string; -}) { - const { form } = useForm({ - defaultValue: initialValue ? { inference_id: initialValue } : undefined, - }); - - return
{children}
; -} - -export function setupInferenceEndpointsMocks({ - data = DEFAULT_ENDPOINTS, - isLoading = false, - error = null, -}: { - data?: InferenceAPIConfigResponse[] | undefined; - isLoading?: boolean; - error?: ReturnType['error']; -} = {}) { - const useLoadInferenceEndpointsMock = jest.mocked(useLoadInferenceEndpoints); - - mockResendRequest.mockClear(); - useLoadInferenceEndpointsMock.mockReturnValue({ - data, - isInitialRequest: false, - isLoading, - error, - resendRequest: mockResendRequest, - }); -} - -export const renderSelectInferenceId = ({ - initialValue, - props = defaultProps, -}: { - initialValue?: string; - props?: SelectInferenceIdProps; -} = {}) => - render( - - - - ); - -export const installConsoleTruncationWarningFilter = () => { - // eslint-disable-next-line no-console - const originalConsoleError = console.error; - const consoleErrorSpy = jest - .spyOn(console, 'error') - .mockImplementation((message?: unknown, ...rest: unknown[]) => { - const isTruncationWarning = - typeof message === 'string' && - message.includes('The truncation ellipsis is larger than the available width'); - - if (isTruncationWarning) { - return; - } - originalConsoleError(message, ...rest); - }); - - return () => { - consoleErrorSpy.mockRestore(); - }; -}; diff --git a/x-pack/platform/plugins/shared/index_management/__jest__/client_integration/index_template_wizard/template_clone.test.tsx b/x-pack/platform/plugins/shared/index_management/__jest__/client_integration/index_template_wizard/template_clone.test.tsx deleted file mode 100644 index 807339862c5a8..0000000000000 --- a/x-pack/platform/plugins/shared/index_management/__jest__/client_integration/index_template_wizard/template_clone.test.tsx +++ /dev/null @@ -1,96 +0,0 @@ -/* - * 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 { screen, fireEvent, waitFor } from '@testing-library/react'; - -import { API_BASE_PATH } from '../../../common/constants'; -import { setupEnvironment } from '../helpers/setup_environment'; -import { - DEFAULT_INDEX_PATTERNS_FOR_CLONE, - completeStep, - renderTemplateClone, - templateToClone, -} from './template_clone.helpers'; - -jest.mock('@kbn/code-editor'); - -describe('', () => { - let httpSetup: ReturnType['httpSetup']; - let httpRequestsMockHelpers: ReturnType['httpRequestsMockHelpers']; - - beforeEach(() => { - jest.restoreAllMocks(); - jest.clearAllMocks(); - const env = setupEnvironment(); - httpSetup = env.httpSetup; - httpRequestsMockHelpers = env.httpRequestsMockHelpers; - httpRequestsMockHelpers.setLoadTelemetryResponse({}); - httpRequestsMockHelpers.setLoadComponentTemplatesResponse([]); - httpRequestsMockHelpers.setLoadTemplateResponse(templateToClone.name, templateToClone); - }); - - describe('page title', () => { - beforeEach(async () => { - renderTemplateClone(httpSetup); - await screen.findByTestId('pageTitle'); - }); - - test('should set the correct page title', () => { - expect(screen.getByTestId('pageTitle')).toBeInTheDocument(); - expect(screen.getByTestId('pageTitle')).toHaveTextContent( - `Clone template '${templateToClone.name}'` - ); - }); - }); - - describe('form payload', () => { - beforeEach(async () => { - renderTemplateClone(httpSetup); - await screen.findByTestId('pageTitle'); - - // Logistics - // Specify index patterns, but do not change name (keep default) - await completeStep.one({ - indexPatterns: DEFAULT_INDEX_PATTERNS_FOR_CLONE, - }); - // Component templates - await completeStep.two(); - // Index settings - await completeStep.three(); - // Mappings - await completeStep.four(); - // Aliases - await completeStep.five(); - }, 20000); - - it('should send the correct payload', async () => { - await waitFor(() => { - expect(screen.getByTestId('nextButton')).toBeEnabled(); - }); - fireEvent.click(screen.getByTestId('nextButton')); - - const { template, indexMode, priority, version, _kbnMeta, allowAutoCreate } = templateToClone; - await waitFor(() => { - expect(httpSetup.post).toHaveBeenLastCalledWith( - `${API_BASE_PATH}/index_templates`, - expect.objectContaining({ - body: JSON.stringify({ - name: `${templateToClone.name}-copy`, - indexPatterns: DEFAULT_INDEX_PATTERNS_FOR_CLONE, - priority, - version, - allowAutoCreate, - indexMode, - _kbnMeta, - template, - }), - }) - ); - }); - }, 20000); - }); -}); diff --git a/x-pack/platform/plugins/shared/index_management/__jest__/client_integration/index_template_wizard/template_edit.test.tsx b/x-pack/platform/plugins/shared/index_management/__jest__/client_integration/index_template_wizard/template_edit.test.tsx deleted file mode 100644 index 46e7c26fa8dc6..0000000000000 --- a/x-pack/platform/plugins/shared/index_management/__jest__/client_integration/index_template_wizard/template_edit.test.tsx +++ /dev/null @@ -1,332 +0,0 @@ -/* - * 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 { screen, fireEvent, within, waitFor } from '@testing-library/react'; - -import * as fixtures from '../../../test/fixtures'; -import { API_BASE_PATH } from '../../../common/constants'; - -import { TEMPLATE_NAME, SETTINGS, ALIASES, INDEX_PATTERNS } from './constants'; -import { kibanaVersion, setupEnvironment } from '../helpers/setup_environment'; -import { - EXISTING_COMPONENT_TEMPLATE, - MAPPING, - NONEXISTENT_COMPONENT_TEMPLATE, - UPDATED_INDEX_PATTERN, - UPDATED_MAPPING_TEXT_FIELD_NAME, - completeStep, - renderTemplateEdit, -} from './template_edit.helpers'; - -jest.mock('@kbn/code-editor'); - -describe('', () => { - let httpSetup: ReturnType['httpSetup']; - let httpRequestsMockHelpers: ReturnType['httpRequestsMockHelpers']; - - beforeEach(() => { - jest.restoreAllMocks(); - jest.clearAllMocks(); - const env = setupEnvironment(); - httpSetup = env.httpSetup; - httpRequestsMockHelpers = env.httpRequestsMockHelpers; - httpRequestsMockHelpers.setLoadComponentTemplatesResponse([]); - httpRequestsMockHelpers.setLoadComponentTemplatesResponse([EXISTING_COMPONENT_TEMPLATE]); - }); - - describe('without mappings', () => { - const templateToEdit = fixtures.getTemplate({ - name: 'index_template_without_mappings', - indexPatterns: ['indexPattern1'], - dataStream: { - hidden: true, - anyUnknownKey: 'should_be_kept', - }, - }); - - beforeEach(async () => { - httpRequestsMockHelpers.setLoadTemplateResponse('my_template', templateToEdit); - renderTemplateEdit(httpSetup); - - await screen.findByTestId('pageTitle'); - }); - - it('allows adding a mapping and preserves data stream config in the saved payload', async () => { - // Complete all steps up to mappings - await completeStep.one({ version: 1, lifecycle: { value: 1, unit: 'd' } }); - await completeStep.two(); - await completeStep.three(); - - // Now on mappings step - add a field using the default field type ("text") - const nameInput = screen.getByTestId('nameParameterInput'); - fireEvent.change(nameInput, { target: { value: 'field_1' } }); - fireEvent.click(screen.getByTestId('addButton')); - - await waitFor(() => expect(screen.getAllByTestId(/fieldsListItem/)).toHaveLength(1)); - - // Complete remaining steps and submit - await completeStep.four(); - await completeStep.five(); - fireEvent.click(screen.getByTestId('nextButton')); - - await waitFor(() => expect(httpSetup.put).toHaveBeenCalled()); - - const putMock = httpSetup.put as unknown as jest.Mock; - const lastCall = putMock.mock.calls[putMock.mock.calls.length - 1] as [ - string, - { body: string } - ]; - const [, { body: requestBody }] = lastCall; - const body = JSON.parse(requestBody) as { dataStream?: unknown; template?: any }; - - expect(body.dataStream).toEqual({ - hidden: true, - anyUnknownKey: 'should_be_kept', - }); - expect(body.template?.mappings?.properties?.field_1?.type).toBe('text'); - }); - }); - - describe('with mappings', () => { - const templateToEdit = fixtures.getTemplate({ - name: TEMPLATE_NAME, - indexPatterns: ['indexPattern1'], - template: { - mappings: MAPPING, - }, - }); - - beforeEach(async () => { - jest.clearAllMocks(); - httpRequestsMockHelpers.setLoadTemplateResponse('my_template', templateToEdit); - - renderTemplateEdit(httpSetup); - - await screen.findByTestId('pageTitle'); - }); - - test('should set the correct page title', () => { - const { name } = templateToEdit; - - expect(screen.getByTestId('pageTitle')).toBeInTheDocument(); - expect(screen.getByTestId('pageTitle')).toHaveTextContent(`Edit template '${name}'`); - }); - - it('should set the nameField to readOnly', () => { - const nameRow = screen.getByTestId('nameField'); - const nameInput = within(nameRow).getByRole('textbox'); - expect(nameInput).toBeDisabled(); - }); - - describe('form payload', () => { - beforeEach(async () => { - // Complete all steps up to mappings - await completeStep.one({ - indexPatterns: UPDATED_INDEX_PATTERN, - priority: 3, - allowAutoCreate: 'TRUE', - }); - await completeStep.two(); - await completeStep.three(JSON.stringify(SETTINGS)); - }, 20000); - - it('should send the correct payload with changed values', async () => { - // Now on mappings step - edit the text_datatype field (avoid "first item wins") - const fieldItem = screen.getByTestId( - (content) => content.startsWith('fieldsListItem ') && content.includes('text_datatype') - ); - fireEvent.click(within(fieldItem).getByTestId('editFieldButton')); - - await screen.findByTestId('mappingsEditorFieldEdit'); - - // Change field name - const nameInput = screen.getByTestId('nameParameterInput'); - fireEvent.change(nameInput, { target: { value: UPDATED_MAPPING_TEXT_FIELD_NAME } }); - - // Save changes - fireEvent.click(screen.getByTestId('editFieldUpdateButton')); - - await waitFor(() => { - expect(screen.queryByTestId('mappingsEditorFieldEdit')).not.toBeInTheDocument(); - }); - - // Complete remaining steps - await completeStep.four(); - await completeStep.five(JSON.stringify(ALIASES)); - - // Submit the form - fireEvent.click(screen.getByTestId('nextButton')); - - await waitFor(() => { - expect(httpSetup.put).toHaveBeenLastCalledWith( - `${API_BASE_PATH}/index_templates/${TEMPLATE_NAME}`, - expect.objectContaining({ - body: JSON.stringify({ - name: TEMPLATE_NAME, - indexPatterns: UPDATED_INDEX_PATTERN, - priority: 3, - version: templateToEdit.version, - allowAutoCreate: 'TRUE', - indexMode: 'standard', - _kbnMeta: { - type: 'default', - hasDatastream: false, - isLegacy: templateToEdit._kbnMeta.isLegacy, - }, - template: { - settings: SETTINGS, - mappings: { - properties: { - [UPDATED_MAPPING_TEXT_FIELD_NAME]: { - type: 'text', - index: true, - eager_global_ordinals: false, - index_phrases: false, - norms: true, - fielddata: false, - store: false, - index_options: 'positions', - }, - }, - }, - aliases: ALIASES, - }, - }), - }) - ); - }); - }, 6000); - }); - }); - - describe('when composed of a nonexistent component template', () => { - const templateToEdit = fixtures.getTemplate({ - name: TEMPLATE_NAME, - indexPatterns: INDEX_PATTERNS, - composedOf: [NONEXISTENT_COMPONENT_TEMPLATE.name], - ignoreMissingComponentTemplates: [NONEXISTENT_COMPONENT_TEMPLATE.name], - }); - - beforeEach(async () => { - jest.clearAllMocks(); - - httpRequestsMockHelpers.setLoadTemplateResponse('my_template', templateToEdit); - renderTemplateEdit(httpSetup); - - await screen.findByTestId('pageTitle'); - }); - - it('the nonexistent component template should be selected in the Component templates selector', async () => { - // Complete step 1: Logistics - await completeStep.one(); - - // Verify nonexistent template is selected - expect( - screen.queryByTestId('componentTemplatesSelection.emptyPrompt') - ).not.toBeInTheDocument(); - - const selectedList = screen.getByTestId('componentTemplatesSelection'); - const selectedTemplate = within(selectedList).getByTestId('name'); - expect(selectedTemplate).toHaveTextContent(NONEXISTENT_COMPONENT_TEMPLATE.name); - }); - - it('the composedOf and ignoreMissingComponentTemplates fields should be included in the final payload', async () => { - // Complete all steps - await completeStep.one(); - await completeStep.two(); - await completeStep.three(); - await completeStep.four(); - await completeStep.five(); - - expect(screen.getByTestId('stepTitle')).toHaveTextContent( - `Review details for '${TEMPLATE_NAME}'` - ); - - // Submit form - fireEvent.click(screen.getByTestId('nextButton')); - - await waitFor(() => { - expect(httpSetup.put).toHaveBeenLastCalledWith( - `${API_BASE_PATH}/index_templates/${TEMPLATE_NAME}`, - expect.objectContaining({ - body: JSON.stringify({ - name: TEMPLATE_NAME, - indexPatterns: INDEX_PATTERNS, - version: templateToEdit.version, - allowAutoCreate: templateToEdit.allowAutoCreate, - indexMode: templateToEdit.indexMode, - _kbnMeta: templateToEdit._kbnMeta, - composedOf: [NONEXISTENT_COMPONENT_TEMPLATE.name], - template: {}, - ignoreMissingComponentTemplates: [NONEXISTENT_COMPONENT_TEMPLATE.name], - }), - }) - ); - }); - }); - }); - - if (kibanaVersion.major < 8) { - describe('legacy index templates', () => { - const legacyTemplateToEdit = fixtures.getTemplate({ - name: 'legacy_index_template', - indexPatterns: ['indexPattern1'], - isLegacy: true, - template: { - mappings: { - my_mapping_type: {}, - }, - }, - }); - - beforeEach(async () => { - httpSetup.get.mockClear(); - httpSetup.put.mockClear(); - - httpRequestsMockHelpers.setLoadTemplateResponse('my_template', legacyTemplateToEdit); - - renderTemplateEdit(httpSetup, legacyTemplateToEdit.name); - - await screen.findByTestId('pageTitle'); - }); - - it('persists mappings type', async () => { - await completeStep.one(); - - // For legacy templates, step 2 (component templates) doesn't exist, so we go directly to settings (step 3) - await completeStep.three(); - await completeStep.four(); - await completeStep.five(); - - // Submit the form - fireEvent.click(screen.getByTestId('nextButton')); - - const { version, template, name, indexPatterns, _kbnMeta, order } = legacyTemplateToEdit; - - await waitFor(() => { - expect(httpSetup.put).toHaveBeenLastCalledWith( - `${API_BASE_PATH}/index_templates/${TEMPLATE_NAME}`, - expect.objectContaining({ - body: JSON.stringify({ - name, - indexPatterns, - version, - order, - template: { - aliases: undefined, - mappings: template!.mappings, - settings: undefined, - }, - _kbnMeta, - }), - }) - ); - }); - }); - }); - } -}); diff --git a/x-pack/platform/plugins/shared/index_management/public/application/components/mappings_editor/__jest__/client_integration/datatypes/text_datatype.helpers.tsx b/x-pack/platform/plugins/shared/index_management/public/application/components/mappings_editor/__jest__/client_integration/datatypes/text_datatype.helpers.tsx deleted file mode 100644 index 5732c396dcc69..0000000000000 --- a/x-pack/platform/plugins/shared/index_management/public/application/components/mappings_editor/__jest__/client_integration/datatypes/text_datatype.helpers.tsx +++ /dev/null @@ -1,69 +0,0 @@ -/* - * 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 { fireEvent, screen, waitFor, within } from '@testing-library/react'; -import { EuiSuperSelectTestHarness } from '@kbn/test-eui-helpers'; - -export const onChangeHandler = jest.fn(); - -export interface TestMappings { - properties: Record>; - _meta?: Record; - _source?: Record; -} - -export const openFieldEditor = async () => { - const editButton = screen.getByTestId('editFieldButton'); - fireEvent.click(editButton); - return screen.findByTestId('mappingsEditorFieldEdit'); -}; - -export const toggleAdvancedSettings = async (flyout: HTMLElement) => { - const advancedToggle = within(flyout).getByTestId('toggleAdvancedSetting'); - fireEvent.click(advancedToggle); - await waitFor(() => { - const advancedSettings = within(flyout).getByTestId('advancedSettings'); - expect(advancedSettings.style.display).not.toBe('none'); - }); -}; - -export const updateFieldName = (flyout: HTMLElement, name: string) => { - const nameInput = within(flyout).getByTestId('nameParameterInput'); - fireEvent.change(nameInput, { target: { value: name } }); -}; - -export const submitForm = async (flyout: HTMLElement) => { - const updateButton = within(flyout).getByTestId('editFieldUpdateButton'); - fireEvent.click(updateButton); - await waitFor(() => { - expect(onChangeHandler).toHaveBeenCalled(); - }); -}; - -export const getLatestMappings = () => { - const [callData] = onChangeHandler.mock.calls[onChangeHandler.mock.calls.length - 1]; - return callData.getData(); -}; - -export const selectSuperSelectOptionById = async ( - testSubj: string, - optionId: string, - options?: { container?: HTMLElement } -) => { - const harness = new EuiSuperSelectTestHarness(testSubj, { container: options?.container }); - if (!harness.getElement()) throw new Error(`${testSubj} harness not found`); - - await harness.selectById(optionId); -}; - -export const selectAnalyzer = async (flyout: HTMLElement, testSubj: string, optionId: string) => { - await selectSuperSelectOptionById(testSubj, optionId, { container: flyout }); -}; - -export const toggleUseSameSearchAnalyzer = (flyout: HTMLElement) => { - fireEvent.click(within(flyout).getByRole('checkbox')); -}; diff --git a/x-pack/platform/plugins/shared/index_management/public/application/components/mappings_editor/__jest__/client_integration/datatypes/text_datatype.test.tsx b/x-pack/platform/plugins/shared/index_management/public/application/components/mappings_editor/__jest__/client_integration/datatypes/text_datatype.test.tsx deleted file mode 100644 index c5e1e345ec3b6..0000000000000 --- a/x-pack/platform/plugins/shared/index_management/public/application/components/mappings_editor/__jest__/client_integration/datatypes/text_datatype.test.tsx +++ /dev/null @@ -1,367 +0,0 @@ -/* - * 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 React from 'react'; -import { render, screen, fireEvent, waitFor, within } from '@testing-library/react'; -import { I18nProvider } from '@kbn/i18n-react'; -import { EuiSuperSelectTestHarness } from '@kbn/test-eui-helpers'; - -import { WithAppDependencies, kibanaVersion } from '../helpers/setup_environment'; -import { MappingsEditor } from '../../../mappings_editor'; -import { getFieldConfig } from '../../../lib'; -import { defaultTextParameters } from './fixtures'; -import type { TestMappings } from './text_datatype.helpers'; -import { - getLatestMappings, - onChangeHandler, - openFieldEditor, - selectAnalyzer, - submitForm, - toggleAdvancedSettings, - toggleUseSameSearchAnalyzer, - updateFieldName, -} from './text_datatype.helpers'; - -beforeEach(() => { - jest.clearAllMocks(); -}); - -// substantial helpers extracted to `text_datatype.helpers.tsx` - -describe('Mappings editor: text datatype', () => { - test('initial view and default parameters values', async () => { - const defaultMappings = { - properties: { - myField: { - type: 'text', - }, - }, - }; - - const Component = WithAppDependencies(MappingsEditor, {}); - render( - - - - ); - - await screen.findByTestId('mappingsEditor'); - - const flyout = await openFieldEditor(); - - updateFieldName(flyout, 'updatedField'); - - // It should have searchable ("index" param) active by default - const indexFieldConfig = getFieldConfig('index'); - const indexParameterSection = within(flyout).getByTestId('indexParameter'); - const indexToggle = within(indexParameterSection).getByTestId('formRowToggle'); - expect(indexToggle.getAttribute('aria-checked')).toBe(String(indexFieldConfig.defaultValue)); - - if (kibanaVersion.major < 7) { - expect(within(flyout).queryByTestId('boostParameterToggle')).toBeInTheDocument(); - } else { - // Since 8.x the boost parameter is deprecated - expect(within(flyout).queryByTestId('boostParameterToggle')).not.toBeInTheDocument(); - } - - await submitForm(flyout); - - // It should have the default parameters values added - const updatedMappings = { - properties: { - updatedField: { - ...defaultTextParameters, - }, - }, - }; - - expect(getLatestMappings()).toEqual(updatedMappings); - }); - - describe('analyzer parameter', () => { - const defaultMappingsWithAnalyzer = { - _meta: {}, - _source: {}, - properties: { - myField: { - type: 'text', - search_quote_analyzer: 'french', - }, - }, - }; - - afterEach(() => { - // Ensure flyout is closed after each test - const flyout = screen.queryByTestId('mappingsEditorFieldEdit'); - if (flyout) { - const closeButton = within(flyout).queryByTestId('euiFlyoutCloseButton'); - if (closeButton) { - fireEvent.click(closeButton); - } - } - }); - - test('should apply default values and show correct initial analyzers', async () => { - const Component = WithAppDependencies(MappingsEditor, {}); - render( - - - - ); - - await screen.findByTestId('mappingsEditor'); - - const newFieldName = 'updatedField'; - - // Edit field, change name, and save to apply defaults - const flyout = await openFieldEditor(); - await toggleAdvancedSettings(flyout); - updateFieldName(flyout, newFieldName); - await submitForm(flyout); - - // Verify default parameters were added - const updatedMappings = { - ...defaultMappingsWithAnalyzer, - properties: { - updatedField: { - ...defaultMappingsWithAnalyzer.properties.myField, - ...defaultTextParameters, - }, - }, - }; - - expect(getLatestMappings()).toEqual(updatedMappings); - - // Re-open and verify initial analyzer states - const flyoutReopened = await openFieldEditor(); - await toggleAdvancedSettings(flyoutReopened); - - // indexAnalyzer should default to "Index default" - const indexAnalyzerHarness = new EuiSuperSelectTestHarness('indexAnalyzer'); - expect(indexAnalyzerHarness.getSelected()).toContain('Index default'); - - // searchQuoteAnalyzer should show 'french' language - const allSelects = within(flyoutReopened).getAllByTestId('select'); - const frenchAnalyzerSelect = allSelects.find( - (el) => - el.tagName === 'SELECT' && - (el as HTMLSelectElement).value === - defaultMappingsWithAnalyzer.properties.myField.search_quote_analyzer - ) as HTMLSelectElement; - expect(frenchAnalyzerSelect).toHaveValue('french'); - - // "Use same analyzer for search" should be checked - expect(within(flyoutReopened).getByRole('checkbox')).toBeChecked(); - - // searchAnalyzer should not exist when checkbox is checked - expect(within(flyoutReopened).queryByTestId('searchAnalyzer')).not.toBeInTheDocument(); - }, 20000); - - // Checkbox toggle behavior is unit tested in analyzer_parameter.test.tsx - - test('should persist updated analyzer values after save', async () => { - const Component = WithAppDependencies(MappingsEditor, {}); - render( - - - - ); - - await screen.findByTestId('mappingsEditor'); - - let flyout = await openFieldEditor(); - await toggleAdvancedSettings(flyout); - updateFieldName(flyout, 'updatedField'); - - // Change indexAnalyzer from default to 'standard' - await selectAnalyzer(flyout, 'indexAnalyzer', 'standard'); - - // Uncheck "use same analyzer" to reveal searchAnalyzer - toggleUseSameSearchAnalyzer(flyout); - await waitFor(() => { - expect(within(flyout).queryByTestId('searchAnalyzer')).toBeInTheDocument(); - }); - - // Change searchAnalyzer to 'simple' - await selectAnalyzer(flyout, 'searchAnalyzer', 'simple'); - - // Change searchQuoteAnalyzer from language (french) to built-in (whitespace) - await selectAnalyzer(flyout, 'searchQuoteAnalyzer', 'whitespace'); - - await submitForm(flyout); - - const updatedMappings = { - ...defaultMappingsWithAnalyzer, - properties: { - updatedField: { - ...defaultMappingsWithAnalyzer.properties.myField, - ...defaultTextParameters, - analyzer: 'standard', - search_analyzer: 'simple', - search_quote_analyzer: 'whitespace', - }, - }, - }; - - expect(getLatestMappings()).toEqual(updatedMappings); - - // Re-open and verify all analyzer values persisted in the UI - flyout = await openFieldEditor(); - await toggleAdvancedSettings(flyout); - - const indexAnalyzerHarness = new EuiSuperSelectTestHarness('indexAnalyzer'); - expect(indexAnalyzerHarness.getSelected()).toContain('Standard'); - - // searchAnalyzer should be visible (checkbox unchecked since search_analyzer was saved) - await waitFor(() => { - expect(within(flyout).queryByTestId('searchAnalyzer')).toBeInTheDocument(); - }); - const searchAnalyzerHarness = new EuiSuperSelectTestHarness('searchAnalyzer'); - expect(searchAnalyzerHarness.getSelected()).toContain('Simple'); - - const searchQuoteAnalyzerHarness = new EuiSuperSelectTestHarness('searchQuoteAnalyzer'); - expect(searchQuoteAnalyzerHarness.getElement()).toBeInTheDocument(); - expect(searchQuoteAnalyzerHarness.getSelected()).toContain('Whitespace'); - }, 15000); - - // Custom/built-in mode rendering and toggle behavior are unit tested - // in analyzer_parameter.test.tsx. This integration test verifies that - // custom analyzer changes serialize correctly through the full form pipeline. - test('should correctly serialize custom analyzer changes', async () => { - const defaultMappings: TestMappings = { - _meta: {}, - _source: {}, - properties: { - myField: { - type: 'text', - analyzer: 'myCustomIndexAnalyzer', - search_analyzer: 'myCustomSearchAnalyzer', - search_quote_analyzer: 'myCustomSearchQuoteAnalyzer', - }, - }, - }; - - const Component = WithAppDependencies(MappingsEditor, {}); - render( - - - - ); - - await screen.findByTestId('mappingsEditor'); - - const flyout = await openFieldEditor(); - await toggleAdvancedSettings(flyout); - - // Change index analyzer to a different custom value - const indexAnalyzerCustom = within(flyout).getByDisplayValue( - 'myCustomIndexAnalyzer' - ) as HTMLInputElement; - fireEvent.change(indexAnalyzerCustom, { target: { value: 'newCustomIndexAnalyzer' } }); - - // Toggle search analyzer from custom to built-in and select 'whitespace' - fireEvent.click(within(flyout).getByTestId('searchAnalyzer-toggleCustomButton')); - await waitFor(() => { - expect(within(flyout).queryByTestId('searchAnalyzer-custom')).not.toBeInTheDocument(); - }); - await selectAnalyzer(flyout, 'searchAnalyzer', 'whitespace'); - - // Toggle searchQuote analyzer from custom to built-in (defaults to "index default") - fireEvent.click(within(flyout).getByTestId('searchQuoteAnalyzer-toggleCustomButton')); - await waitFor(() => { - const searchQuoteHarness = new EuiSuperSelectTestHarness('searchQuoteAnalyzer'); - expect(searchQuoteHarness.getElement()).toBeInTheDocument(); - expect(searchQuoteHarness.getSelected()).toContain('Index default'); - }); - - await submitForm(flyout); - - const expectedMappings: TestMappings = { - ...defaultMappings, - properties: { - myField: { - ...defaultMappings.properties.myField, - ...defaultTextParameters, - analyzer: 'newCustomIndexAnalyzer', - search_analyzer: 'whitespace', - search_quote_analyzer: undefined, - }, - }, - }; - - expect(getLatestMappings()).toEqual(expectedMappings); - }, 10000); - - // Index settings analyzer rendering is unit tested in analyzer_parameter.test.tsx. - // This integration test verifies that changing an index settings analyzer serializes correctly. - test('should correctly serialize index settings analyzer changes', async () => { - const indexSettings = { - analysis: { - analyzer: { - // eslint-disable-next-line @typescript-eslint/naming-convention - customAnalyzer_1: { type: 'custom', tokenizer: 'standard' }, - // eslint-disable-next-line @typescript-eslint/naming-convention - customAnalyzer_2: { type: 'custom', tokenizer: 'standard' }, - // eslint-disable-next-line @typescript-eslint/naming-convention - customAnalyzer_3: { type: 'custom', tokenizer: 'standard' }, - }, - }, - }; - - const defaultMappings: TestMappings = { - properties: { - myField: { type: 'text', analyzer: 'customAnalyzer_1' }, - }, - }; - - const Component = WithAppDependencies(MappingsEditor, {}); - render( - - - - ); - - await screen.findByTestId('mappingsEditor'); - - const flyout = await openFieldEditor(); - await toggleAdvancedSettings(flyout); - - // Wait for the custom analyzer native select to appear, then change value - const customSelect = await waitFor(() => { - const el = within(flyout).getByDisplayValue('customAnalyzer_1'); - expect(el.tagName).toBe('SELECT'); - return el as HTMLSelectElement; - }); - - fireEvent.change(customSelect, { target: { value: 'customAnalyzer_3' } }); - - await submitForm(flyout); - - expect(getLatestMappings()).toEqual({ - properties: { - myField: { - ...defaultMappings.properties.myField, - ...defaultTextParameters, - analyzer: 'customAnalyzer_3', - }, - }, - }); - }, 10000); - }); -}); diff --git a/x-pack/platform/plugins/shared/index_management/public/application/components/mappings_editor/__jest__/client_integration/edit_field.test.tsx b/x-pack/platform/plugins/shared/index_management/public/application/components/mappings_editor/__jest__/client_integration/edit_field.test.tsx deleted file mode 100644 index 14c44071f61bb..0000000000000 --- a/x-pack/platform/plugins/shared/index_management/public/application/components/mappings_editor/__jest__/client_integration/edit_field.test.tsx +++ /dev/null @@ -1,224 +0,0 @@ -/* - * 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 React from 'react'; -import type { ComponentProps } from 'react'; -import { render, screen, fireEvent, within, waitFor } from '@testing-library/react'; -import { I18nProvider } from '@kbn/i18n-react'; -import { EuiComboBoxTestHarness } from '@kbn/test-eui-helpers'; - -import { MappingsEditor } from '../../mappings_editor'; -import { WithAppDependencies } from './helpers/setup_environment'; -import { defaultTextParameters } from './datatypes/fixtures'; -import { defaultDateRangeParameters } from './datatypes/fixtures'; - -const onChangeHandler = jest.fn(); - -beforeEach(() => { - jest.clearAllMocks(); -}); - -// FLAKY: https://github.com/elastic/kibana/issues/253534 -describe.skip('Mappings editor: edit field', () => { - const getDocumentFields = () => screen.getByTestId('documentFields'); - const getFieldsListItems = () => - within(getDocumentFields()).getAllByTestId((content) => content.startsWith('fieldsListItem ')); - - const getFieldListItemByName = (name: string) => { - const items = getFieldsListItems(); - const item = items.find((it) => { - const fieldNameEls = within(it).queryAllByTestId(/fieldName/); - return fieldNameEls.some((el) => { - if ((el.textContent || '').trim() !== name) return false; - - // Ensure this fieldName belongs to THIS list item, not a nested child item. - let node: HTMLElement | null = el as HTMLElement; - while (node && node !== it) { - const subj = node.getAttribute('data-test-subj'); - if (typeof subj === 'string' && subj.startsWith('fieldsListItem ')) return false; - node = node.parentElement; - } - - return true; - }); - }); - - if (!item) { - throw new Error(`Expected field list item "${name}" to exist`); - } - - return item; - }; - - type MappingsEditorProps = ComponentProps; - - const setup = (props: Partial) => { - const Component = WithAppDependencies(MappingsEditor, {}); - return render( - - - - ); - }; - - test('should open a flyout with the correct field to edit', async () => { - const defaultMappings = { - properties: { - user: { - type: 'object', - properties: { - address: { - type: 'object', - properties: { - street: { type: 'text' }, - }, - }, - }, - }, - }, - }; - - setup({ value: defaultMappings, onChange: onChangeHandler, indexSettings: {} }); - - await screen.findByTestId('mappingsEditor'); - - await screen.findByTestId('fieldsList'); - - // Find all field list items - const allFields = screen.getAllByTestId(/fieldsListItem/); - - // The user field should be the first root-level field - const userField = allFields.find((el) => el.textContent?.includes('user')); - expect(userField).toBeDefined(); - - // Expand user field - const userExpandButton = within(userField!).getByTestId('toggleExpandButton'); - fireEvent.click(userExpandButton); - - const addressListItem = getFieldListItemByName('address'); - - // Expand address field - const addressExpandButton = within(addressListItem).getByRole('button', { - name: /field address/i, - }); - fireEvent.click(addressExpandButton); - - const streetListItem = getFieldListItemByName('street'); - - // Click edit button for street field - const streetEditButton = within(streetListItem).getByTestId('editFieldButton'); - fireEvent.click(streetEditButton); - - const flyout = await screen.findByTestId('mappingsEditorFieldEdit'); - - // It should have the correct title - const flyoutTitle = within(flyout).getByTestId('flyoutTitle'); - expect(flyoutTitle.textContent).toEqual(`Edit field 'street'`); - - // It should have the correct field path - const fieldPath = within(flyout).getByTestId('fieldPath'); - expect(fieldPath.textContent).toEqual('user > address > street'); - - // The advanced settings should be hidden initially - const advancedSettings = within(flyout).getByTestId('advancedSettings'); - expect(advancedSettings.style.display).toEqual('none'); - }); - - test('should update form parameters when changing the field datatype', async () => { - const defaultMappings = { - properties: { - userName: { - ...defaultTextParameters, - }, - }, - }; - - setup({ value: defaultMappings, onChange: onChangeHandler, indexSettings: {} }); - - await screen.findByTestId('mappingsEditor'); - - await screen.findByTestId('fieldsList'); - - // Find the userName field by text - const userNameListItem = getFieldListItemByName('userName'); - expect(userNameListItem).toBeInTheDocument(); - - // Open the flyout to edit the field - const editButton = within(userNameListItem).getByTestId('editFieldButton'); - fireEvent.click(editButton); - - const flyout = await screen.findByTestId('mappingsEditorFieldEdit'); - - // Change field type to Range using EuiComboBox harness - const fieldTypeComboBox = new EuiComboBoxTestHarness('fieldType'); - await fieldTypeComboBox.select('range'); - await fieldTypeComboBox.close(); - - // Wait for SubTypeParameter to appear (range type has subTypes) - await within(flyout).findByTestId('fieldSubType'); - - // Save and close flyout - const updateButton = within(flyout).getByTestId('editFieldUpdateButton'); - await waitFor(() => { - expect(updateButton).not.toBeDisabled(); - }); - fireEvent.click(updateButton); - - await waitFor(() => { - const lastCall = onChangeHandler.mock.calls[onChangeHandler.mock.calls.length - 1][0]; - const data = lastCall.getData(lastCall.isValid ?? true); - - const updatedMappings = { - ...defaultMappings, - properties: { - userName: { - ...defaultDateRangeParameters, - }, - }, - }; - - // Serialized as subType 'date_range', not main type 'range' - expect(data).toEqual(updatedMappings); - }); - }); - - test('should have Update button enabled only when changes are made', async () => { - const defaultMappings = { - properties: { - myField: { - type: 'text', - }, - }, - }; - - setup({ value: defaultMappings, onChange: onChangeHandler, indexSettings: {} }); - - await screen.findByTestId('mappingsEditor'); - - await screen.findByTestId('fieldsList'); - - // Find the myField field by text - const myFieldListItem = getFieldListItemByName('myField'); - - // Open the flyout to edit the field - const editButton = within(myFieldListItem).getByTestId('editFieldButton'); - fireEvent.click(editButton); - - const flyout = await screen.findByTestId('mappingsEditorFieldEdit'); - - // Update button should be disabled initially (no changes) - const updateButton = within(flyout).getByTestId('editFieldUpdateButton'); - expect(updateButton).toBeDisabled(); - - // Change the field name - const nameInput = within(flyout).getByTestId('nameParameterInput'); - fireEvent.change(nameInput, { target: { value: 'updatedField' } }); - - // Update button should now be enabled - expect(updateButton).not.toBeDisabled(); - }); -}); diff --git a/x-pack/platform/plugins/shared/index_management/public/application/components/mappings_editor/components/document_fields/field_parameters/reference_field_selects.test.tsx b/x-pack/platform/plugins/shared/index_management/public/application/components/mappings_editor/components/document_fields/field_parameters/reference_field_selects.test.tsx new file mode 100644 index 0000000000000..7f0b7da0026d4 --- /dev/null +++ b/x-pack/platform/plugins/shared/index_management/public/application/components/mappings_editor/components/document_fields/field_parameters/reference_field_selects.test.tsx @@ -0,0 +1,137 @@ +/* + * 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 React from 'react'; +import { render, screen, fireEvent, act } from '@testing-library/react'; +import { I18nProvider } from '@kbn/i18n-react'; + +import { Form, useForm, useFormData } from '../../../shared_imports'; +import { ReferenceFieldSelects } from './reference_field_selects'; + +jest.mock('../../../mappings_state_context', () => ({ + useMappingsState: jest.fn(), +})); + +jest.mock('@elastic/eui', () => { + const actual = jest.requireActual('@elastic/eui'); + + return { + ...actual, + EuiSuperSelect: ({ + options, + valueOfSelected, + onChange, + 'data-test-subj': dataTestSubj, + }: { + options: Array<{ value: string; inputDisplay: string; 'data-test-subj'?: string }>; + valueOfSelected: string; + onChange: (value: string) => void; + 'data-test-subj'?: string; + }) => ( + + ), + }; +}); + +const FormWrapper = ({ + children, + defaultValue = {}, +}: { + children: React.ReactNode; + defaultValue?: Record; +}) => { + const { form } = useForm({ defaultValue }); + return ( + +
{children}
+
+ ); +}; + +const mockUseMappingsState = jest.requireMock('../../../mappings_state_context') + .useMappingsState as jest.Mock; + +const ReferenceFieldValueSpy = () => { + const [{ reference_field: referenceField }] = useFormData<{ reference_field?: string }>({ + watch: 'reference_field', + }); + return
{referenceField ?? ''}
; +}; + +describe('ReferenceFieldSelects', () => { + describe('WHEN rendered with mixed field types in mappings state', () => { + it('SHOULD list only non-multi text fields', async () => { + mockUseMappingsState.mockReturnValue({ + mappingViewFields: { + byId: { + title: { source: { type: 'text' }, isMultiField: false, path: ['title'] }, + keyword: { source: { type: 'keyword' }, isMultiField: false, path: ['keyword'] }, + }, + }, + fields: { + byId: { + nestedText: { source: { type: 'text' }, isMultiField: false, path: ['a', 'b'] }, + multiText: { source: { type: 'text' }, isMultiField: true, path: ['multi'] }, + }, + }, + }); + + render( + + + + + ); + + const select = await screen.findByTestId('select'); + const optionValues = Array.from((select as HTMLSelectElement).options).map((o) => o.value); + + expect(optionValues).toContain('title'); + expect(optionValues).toContain('a.b'); + expect(optionValues).not.toContain('keyword'); + expect(optionValues).not.toContain('multi'); + }); + }); + + describe('WHEN an option is selected', () => { + it('SHOULD update the reference_field form value', async () => { + mockUseMappingsState.mockReturnValue({ + mappingViewFields: { + byId: { + title: { source: { type: 'text' }, isMultiField: false, path: ['title'] }, + }, + }, + fields: { byId: {} }, + }); + + render( + + + + + ); + + const select = (await screen.findByTestId('select')) as HTMLSelectElement; + await act(async () => { + fireEvent.change(select, { target: { value: 'title' } }); + fireEvent.blur(select); + }); + + expect(await screen.findByTestId('referenceFieldSpy')).toHaveTextContent('title'); + }); + }); +}); diff --git a/x-pack/platform/plugins/shared/index_management/__jest__/client_integration/index_details_page/select_inference_id.test.tsx b/x-pack/platform/plugins/shared/index_management/public/application/components/mappings_editor/components/document_fields/field_parameters/select_inference_id.test.tsx similarity index 72% rename from x-pack/platform/plugins/shared/index_management/__jest__/client_integration/index_details_page/select_inference_id.test.tsx rename to x-pack/platform/plugins/shared/index_management/public/application/components/mappings_editor/components/document_fields/field_parameters/select_inference_id.test.tsx index 9395b0fb163e6..9a74bbd06ce53 100644 --- a/x-pack/platform/plugins/shared/index_management/__jest__/client_integration/index_details_page/select_inference_id.test.tsx +++ b/x-pack/platform/plugins/shared/index_management/public/application/components/mappings_editor/components/document_fields/field_parameters/select_inference_id.test.tsx @@ -6,89 +6,36 @@ */ import React from 'react'; -import { screen, fireEvent, waitFor } from '@testing-library/react'; +import { act, render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; +import type { InferenceAPIConfigResponse } from '@kbn/ml-trained-models-utils'; import { defaultInferenceEndpoints } from '@kbn/inference-common'; import type { InferenceTaskType } from '@elastic/elasticsearch/lib/api/types'; -import type { InferenceAPIConfigResponse } from '@kbn/ml-trained-models-utils'; -import { - installConsoleTruncationWarningFilter, - mockResendRequest, - renderSelectInferenceId, - setupInferenceEndpointsMocks, -} from './select_inference_id.helpers'; - -const mockDispatch = jest.fn(); -const mockNavigateToUrl = jest.fn(); -jest.mock('../../../public/application/app_context', () => ({ - ...jest.requireActual('../../../public/application/app_context'), - useAppContext: jest.fn(() => ({ - core: { - application: { - navigateToUrl: mockNavigateToUrl, - }, - http: { - basePath: { - get: jest.fn().mockReturnValue('/base-path'), - }, - }, - }, - config: { enforceAdaptiveAllocations: false }, - services: { - notificationService: { - toasts: {}, - }, - }, - docLinks: { - links: { - inferenceManagement: { - inferenceAPIDocumentation: 'https://abc.com/inference-api-create', - }, - }, - }, - plugins: { - share: { - url: { - locators: { - get: jest.fn((id) => { - const { INFERENCE_LOCATOR, createMockLocator } = jest.requireActual( - './select_inference_id.helpers' - ) as typeof import('./select_inference_id.helpers'); - if (id === INFERENCE_LOCATOR) { - return createMockLocator(); - } - throw new Error(`Unknown locator id: ${id}`); - }), - }, - }, - }, - }, - })), -})); +import { Form, useForm } from '../../../shared_imports'; +import { useLoadInferenceEndpoints } from '../../../../../services/api'; +import { SelectInferenceId } from './select_inference_id'; -jest.mock( - '../../../public/application/components/component_templates/component_templates_context', - () => ({ - ...jest.requireActual( - '../../../public/application/components/component_templates/component_templates_context' +jest.mock('@elastic/eui', () => { + const actual = jest.requireActual('@elastic/eui'); + return { + ...actual, + EuiPopover: ({ + button, + children, + isOpen, + }: { + button: React.ReactNode; + children: React.ReactNode; + isOpen: boolean; + }) => ( +
+ {button} + {isOpen ?
{children}
: null} +
), - useComponentTemplatesContext: jest.fn(() => ({ - toasts: { - addError: jest.fn(), - addSuccess: jest.fn(), - }, - })), - }) -); - -jest.mock('../../../public/application/components/mappings_editor/mappings_state_context', () => ({ - ...jest.requireActual( - '../../../public/application/components/mappings_editor/mappings_state_context' - ), - useMappingsState: () => ({ inferenceToModelIdMap: {} }), - useDispatch: () => mockDispatch, -})); + }; +}); jest.mock('@kbn/inference-endpoint-ui-common', () => { const SERVICE_PROVIDERS = { @@ -132,52 +79,178 @@ jest.mock('@kbn/inference-endpoint-ui-common', () => { }; }); -jest.mock('../../../public/application/services/api', () => ({ - ...jest.requireActual('../../../public/application/services/api'), +jest.mock('../../../../../services/api', () => ({ + ...jest.requireActual('../../../../../services/api'), useLoadInferenceEndpoints: jest.fn(), })); -let user: ReturnType; +const mockNavigateToUrl = jest.fn(); -let restoreConsoleErrorFilter: () => void; +jest.mock('../../../../../app_context', () => ({ + ...jest.requireActual('../../../../../app_context'), + useAppContext: jest.fn(() => ({ + core: { + application: { + navigateToUrl: mockNavigateToUrl, + }, + http: { + basePath: { + get: jest.fn().mockReturnValue('/base-path'), + }, + }, + }, + config: { enforceAdaptiveAllocations: false }, + services: { + notificationService: { + toasts: {}, + }, + }, + docLinks: { + links: { + inferenceManagement: { + inferenceAPIDocumentation: 'https://abc.com/inference-api-create', + }, + }, + }, + plugins: { + cloud: { isCloudEnabled: false }, + share: { + url: { + locators: { + get: jest.fn(() => ({ useUrl: jest.fn().mockReturnValue('https://redirect.me') })), + }, + }, + }, + }, + })), +})); -beforeEach(() => { - jest.restoreAllMocks(); - jest.clearAllMocks(); - user = userEvent.setup(); - restoreConsoleErrorFilter = installConsoleTruncationWarningFilter(); -}); +const DEFAULT_ENDPOINTS: InferenceAPIConfigResponse[] = [ + { + inference_id: defaultInferenceEndpoints.JINAv5, + task_type: 'text_embedding', + service: 'jina', + service_settings: { model_id: 'jina-embeddings-v5-text-small' }, + }, + { + inference_id: defaultInferenceEndpoints.ELSER, + task_type: 'sparse_embedding', + service: 'elastic', + service_settings: { model_id: 'elser' }, + }, + { + inference_id: defaultInferenceEndpoints.MULTILINGUAL_E5_SMALL, + task_type: 'text_embedding', + service: 'elastic', + service_settings: { model_id: 'e5' }, + }, + { + inference_id: 'endpoint-1', + task_type: 'text_embedding', + service: 'openai', + service_settings: { model_id: 'text-embedding-3-large' }, + }, + { + inference_id: 'endpoint-2', + task_type: 'sparse_embedding', + service: 'elastic', + service_settings: { model_id: 'elser' }, + }, +] as InferenceAPIConfigResponse[]; + +const mockResendRequest = jest.fn(); + +const setupInferenceEndpointsMocks = ({ + data = DEFAULT_ENDPOINTS, + isLoading = false, + error = null, +}: { + data?: InferenceAPIConfigResponse[] | undefined; + isLoading?: boolean; + error?: ReturnType['error']; +} = {}) => { + mockResendRequest.mockClear(); + jest.mocked(useLoadInferenceEndpoints).mockReturnValue({ + data, + isInitialRequest: false, + isLoading, + error, + resendRequest: mockResendRequest, + }); +}; + +function TestFormWrapper({ + children, + initialValue = defaultInferenceEndpoints.ELSER, +}: { + children: React.ReactElement; + initialValue?: string; +}) { + const { form } = useForm({ + defaultValue: initialValue !== undefined ? { inference_id: initialValue } : undefined, + }); -afterEach(async () => { - restoreConsoleErrorFilter(); -}); + return
{children}
; +} + +const renderSelectInferenceId = async ({ initialValue }: { initialValue?: string } = {}) => { + let result: ReturnType | undefined; + await act(async () => { + result = render( + + + + ); + }); + return result!; +}; describe('SelectInferenceId', () => { + let user: ReturnType; + + beforeEach(() => { + jest.useFakeTimers(); + jest.restoreAllMocks(); + jest.clearAllMocks(); + user = userEvent.setup({ + advanceTimers: jest.advanceTimersByTime, + pointerEventsCheck: 0, + delay: null, + }); + }); + + afterEach(() => { + act(() => { + jest.runOnlyPendingTimers(); + }); + jest.useRealTimers(); + }); + describe('WHEN component is rendered', () => { beforeEach(() => { setupInferenceEndpointsMocks(); }); it('SHOULD display the component with button', async () => { - renderSelectInferenceId(); + await renderSelectInferenceId(); expect(await screen.findByTestId('selectInferenceId')).toBeInTheDocument(); expect(await screen.findByTestId('inferenceIdButton')).toBeInTheDocument(); }); it('SHOULD display selected endpoint in button', async () => { - renderSelectInferenceId({ initialValue: '' }); + await renderSelectInferenceId({ initialValue: '' }); const button = await screen.findByTestId('inferenceIdButton'); expect(button).toHaveTextContent(defaultInferenceEndpoints.JINAv5); }); it('SHOULD prioritize Jina v5 endpoint as default selection', async () => { - renderSelectInferenceId({ initialValue: '' }); + await renderSelectInferenceId({ initialValue: '' }); const button = await screen.findByTestId('inferenceIdButton'); expect(button).toHaveTextContent(defaultInferenceEndpoints.JINAv5); - expect(button).not.toHaveTextContent('.preconfigured-elser'); + expect(button).not.toHaveTextContent(defaultInferenceEndpoints.ELSER); expect(button).not.toHaveTextContent('endpoint-1'); expect(button).not.toHaveTextContent('endpoint-2'); }); @@ -189,7 +262,7 @@ describe('SelectInferenceId', () => { }); it('SHOULD open popover with management buttons', async () => { - renderSelectInferenceId(); + await renderSelectInferenceId(); await user.click(await screen.findByTestId('inferenceIdButton')); @@ -199,7 +272,7 @@ describe('SelectInferenceId', () => { describe('AND button is clicked again', () => { it('SHOULD close the popover', async () => { - renderSelectInferenceId(); + await renderSelectInferenceId(); const toggle = await screen.findByTestId('inferenceIdButton'); @@ -215,7 +288,7 @@ describe('SelectInferenceId', () => { describe('AND "Add inference endpoint" button is clicked', () => { it('SHOULD close popover', async () => { - renderSelectInferenceId(); + await renderSelectInferenceId(); await user.click(await screen.findByTestId('inferenceIdButton')); expect(await screen.findByTestId('createInferenceEndpointButton')).toBeInTheDocument(); @@ -234,7 +307,7 @@ describe('SelectInferenceId', () => { }); it('SHOULD display newly created endpoint even if not in loaded list', async () => { - renderSelectInferenceId({ initialValue: 'newly-created-endpoint' }); + await renderSelectInferenceId({ initialValue: 'newly-created-endpoint' }); await user.click(await screen.findByTestId('inferenceIdButton')); @@ -250,7 +323,7 @@ describe('SelectInferenceId', () => { }); it('SHOULD show flyout when "Add inference endpoint" is clicked', async () => { - renderSelectInferenceId(); + await renderSelectInferenceId(); await user.click(await screen.findByTestId('inferenceIdButton')); await user.click(await screen.findByTestId('createInferenceEndpointButton')); @@ -259,7 +332,7 @@ describe('SelectInferenceId', () => { }); it('SHOULD pass allowedTaskTypes to restrict endpoint creation to compatible types', async () => { - renderSelectInferenceId(); + await renderSelectInferenceId(); await user.click(await screen.findByTestId('inferenceIdButton')); await user.click(await screen.findByTestId('createInferenceEndpointButton')); @@ -270,7 +343,7 @@ describe('SelectInferenceId', () => { describe('AND flyout close is triggered', () => { it('SHOULD close the flyout', async () => { - renderSelectInferenceId(); + await renderSelectInferenceId(); await user.click(await screen.findByTestId('inferenceIdButton')); await user.click(await screen.findByTestId('createInferenceEndpointButton')); @@ -284,7 +357,7 @@ describe('SelectInferenceId', () => { describe('AND endpoint is successfully created', () => { it('SHOULD call resendRequest when submitted', async () => { - renderSelectInferenceId(); + await renderSelectInferenceId(); await user.click(await screen.findByTestId('inferenceIdButton')); await user.click(await screen.findByTestId('createInferenceEndpointButton')); @@ -304,7 +377,7 @@ describe('SelectInferenceId', () => { }); it('SHOULD update form value with selected endpoint', async () => { - renderSelectInferenceId(); + await renderSelectInferenceId(); await user.click(await screen.findByTestId('inferenceIdButton')); @@ -322,14 +395,15 @@ describe('SelectInferenceId', () => { }); it('SHOULD filter endpoints based on search input', async () => { - renderSelectInferenceId(); + await renderSelectInferenceId(); await user.click(await screen.findByTestId('inferenceIdButton')); const searchInput = await screen.findByRole('combobox', { name: /Existing endpoints/i, }); - fireEvent.change(searchInput, { target: { value: 'endpoint-1' } }); + await user.clear(searchInput); + await user.type(searchInput, 'endpoint-1'); expect(await screen.findByTestId('custom-inference_endpoint-1')).toBeInTheDocument(); expect(screen.queryByTestId('custom-inference_endpoint-2')).not.toBeInTheDocument(); @@ -340,7 +414,7 @@ describe('SelectInferenceId', () => { it('SHOULD display loading spinner', async () => { setupInferenceEndpointsMocks({ data: undefined, isLoading: true, error: null }); - renderSelectInferenceId(); + await renderSelectInferenceId(); await user.click(await screen.findByTestId('inferenceIdButton')); await screen.findByTestId('createInferenceEndpointButton'); @@ -354,16 +428,16 @@ describe('SelectInferenceId', () => { it('SHOULD not set default value', async () => { setupInferenceEndpointsMocks({ data: [], isLoading: false, error: null }); - renderSelectInferenceId({ initialValue: '' }); + await renderSelectInferenceId({ initialValue: '' }); const button = screen.getByTestId('inferenceIdButton'); expect(button).toHaveTextContent(defaultInferenceEndpoints.ELSER); }); - it('SHOULD default to the ELSER endpoint when no endpoints are returned', () => { + it('SHOULD default to the ELSER endpoint when no endpoints are returned', async () => { setupInferenceEndpointsMocks({ data: [], isLoading: false, error: null }); - renderSelectInferenceId({ initialValue: '' }); + await renderSelectInferenceId({ initialValue: '' }); const button = screen.getByTestId('inferenceIdButton'); expect(button).toHaveTextContent(defaultInferenceEndpoints.ELSER); @@ -381,7 +455,7 @@ describe('SelectInferenceId', () => { }); it('SHOULD not display incompatible endpoints in list', async () => { - renderSelectInferenceId({ initialValue: '' }); + await renderSelectInferenceId({ initialValue: '' }); await user.click(await screen.findByTestId('inferenceIdButton')); await screen.findByTestId('createInferenceEndpointButton'); @@ -402,7 +476,7 @@ describe('SelectInferenceId', () => { }, }); - renderSelectInferenceId(); + await renderSelectInferenceId(); expect(screen.getByTestId('selectInferenceId')).toBeInTheDocument(); @@ -418,7 +492,7 @@ describe('SelectInferenceId', () => { it('SHOULD automatically select default endpoint', async () => { setupInferenceEndpointsMocks(); - renderSelectInferenceId({ initialValue: '' }); + await renderSelectInferenceId({ initialValue: '' }); const button = await screen.findByTestId('inferenceIdButton'); await waitFor(() => expect(button).toHaveTextContent(defaultInferenceEndpoints.JINAv5)); @@ -449,7 +523,7 @@ describe('SelectInferenceId', () => { ] as InferenceAPIConfigResponse[], }); - renderSelectInferenceId({ initialValue: '' }); + await renderSelectInferenceId({ initialValue: '' }); const button = await screen.findByTestId('inferenceIdButton'); await waitFor(() => diff --git a/x-pack/platform/plugins/shared/index_management/public/application/components/mappings_editor/components/document_fields/field_parameters/type_parameter.test.tsx b/x-pack/platform/plugins/shared/index_management/public/application/components/mappings_editor/components/document_fields/field_parameters/type_parameter.test.tsx new file mode 100644 index 0000000000000..86e6ea94890c9 --- /dev/null +++ b/x-pack/platform/plugins/shared/index_management/public/application/components/mappings_editor/components/document_fields/field_parameters/type_parameter.test.tsx @@ -0,0 +1,105 @@ +/* + * 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 React from 'react'; +import { render, screen } from '@testing-library/react'; +import { I18nProvider } from '@kbn/i18n-react'; + +import { Form, useForm } from '../../../shared_imports'; +import { TYPE_ONLY_ALLOWED_AT_ROOT_LEVEL } from '../../../constants'; +import { TypeParameter } from './type_parameter'; + +jest.mock('../../../../../services/documentation', () => ({ + documentationService: { + getTypeDocLink: (type: string) => `/docs/${type}`, + }, +})); + +jest.mock('@elastic/eui', () => { + const actual = jest.requireActual('@elastic/eui'); + + return { + ...actual, + EuiComboBox: ({ + options, + 'data-test-subj': dataTestSubj, + selectedOptions, + }: { + options: Array<{ value: string }>; + 'data-test-subj'?: string; + selectedOptions: Array<{ value: string; label: string }>; + onChange: (opts: Array<{ value: string; label: string }>) => void; + inputRef?: (input: HTMLInputElement | null) => void; + }) => ( +
+
{options.map((o) => o.value).join(',')}
+
+ {selectedOptions.map((o) => o.value).join(',')} +
+
+ ), + }; +}); + +const FormWrapper = ({ + children, + defaultValue = {}, +}: { + children: React.ReactNode; + defaultValue?: Record; +}) => { + const { form } = useForm({ defaultValue }); + return ( + +
{children}
+
+ ); +}; + +describe('TypeParameter', () => { + describe('WHEN isSemanticTextEnabled is false', () => { + it('SHOULD filter out the semantic_text option', async () => { + render( + + + + ); + + const optionsText = await screen.findByTestId('mockComboBoxOptions'); + expect(optionsText.textContent).not.toContain('semantic_text'); + }); + }); + + describe('WHEN isRootLevelField is false', () => { + it('SHOULD filter out root-only types', async () => { + render( + + + + ); + + const optionsText = await screen.findByTestId('mockComboBoxOptions'); + for (const rootOnlyType of TYPE_ONLY_ALLOWED_AT_ROOT_LEVEL) { + expect(optionsText.textContent).not.toContain(rootOnlyType); + } + }); + }); + + describe('WHEN showDocLink is true and a type is selected', () => { + it('SHOULD render the documentation link', async () => { + render( + + + + ); + + const link = await screen.findByRole('link'); + expect(link).toHaveAttribute('href', '/docs/text'); + expect(link.textContent).toContain('documentation'); + }); + }); +}); diff --git a/x-pack/platform/plugins/shared/index_management/public/application/components/mappings_editor/mappings_editor.edit_field.test.tsx b/x-pack/platform/plugins/shared/index_management/public/application/components/mappings_editor/mappings_editor.edit_field.test.tsx new file mode 100644 index 0000000000000..0e3998d501db3 --- /dev/null +++ b/x-pack/platform/plugins/shared/index_management/public/application/components/mappings_editor/mappings_editor.edit_field.test.tsx @@ -0,0 +1,270 @@ +/* + * 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 React from 'react'; +import type { ComponentProps } from 'react'; +import { render, screen, within, waitFor, fireEvent } from '@testing-library/react'; +import { I18nProvider } from '@kbn/i18n-react'; +import SemVer from 'semver/classes/semver'; +import { docLinksServiceMock, uiSettingsServiceMock } from '@kbn/core/public/mocks'; +import { GlobalFlyout } from '@kbn/es-ui-shared-plugin/public'; + +import { MAJOR_VERSION } from '../../../../common'; +import { useAppContext } from '../../app_context'; +import { MappingsEditor } from './mappings_editor'; +import { MappingsEditorProvider } from './mappings_editor_context'; +import { createKibanaReactContext } from './shared_imports'; + +type UseFieldType = typeof import('./shared_imports').UseField; +type GetFieldConfigType = typeof import('./lib').getFieldConfig; + +jest.mock('@kbn/code-editor'); + +jest.mock('@elastic/eui', () => { + const actual = jest.requireActual('@elastic/eui'); + + return { + ...actual, + EuiPortal: ({ children }: { children?: React.ReactNode }) => <>{children}, + EuiOverlayMask: ({ children }: { children?: React.ReactNode }) => <>{children}, + }; +}); + +jest.mock('./components/document_fields/field_parameters/type_parameter', () => { + const sharedImports = jest.requireActual('./shared_imports'); + const lib = jest.requireActual('./lib'); + const UseFieldActual = sharedImports.UseField as UseFieldType; + const getFieldConfigActual = lib.getFieldConfig as GetFieldConfigType; + + const options = [ + { value: 'text', label: 'text' }, + { value: 'range', label: 'range' }, + { value: 'date_range', label: 'date_range' }, + { value: 'other', label: 'other' }, + ]; + + const TypeParameter = () => ( + > + path="type" + config={getFieldConfigActual>('type')} + > + {(field) => { + return ( + + ); + }} + + ); + + return { __esModule: true, TypeParameter }; +}); + +jest.mock('../../app_context', () => { + const actual = jest.requireActual('../../app_context'); + return { + ...actual, + useAppContext: jest.fn(), + }; +}); + +const { GlobalFlyoutProvider } = GlobalFlyout; +const mockUseAppContext = useAppContext as unknown as jest.MockedFunction; +const docLinks = docLinksServiceMock.createStartContract(); +const kibanaVersion = new SemVer(MAJOR_VERSION); +const { Provider: KibanaReactContextProvider } = createKibanaReactContext({ + uiSettings: uiSettingsServiceMock.createSetupContract(), + kibanaVersion: { + get: () => kibanaVersion, + }, +}); + +const defaultTextParameters = { + type: 'text', + eager_global_ordinals: false, + fielddata: false, + index: true, + index_options: 'positions', + index_phrases: false, + norms: true, + store: false, +}; + +const defaultDateRangeParameters = { + type: 'date_range', + coerce: true, + index: true, + store: false, +}; + +describe('Mappings editor: edit field', () => { + const onChangeHandler = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + mockUseAppContext.mockReturnValue({ + canUseSyntheticSource: true, + config: { + enableMappingsSourceFieldSection: false, + }, + } as unknown as ReturnType); + }); + + const getFieldListItemById = (id: string) => screen.getByTestId(`fieldsListItem ${id}Field`); + + type MappingsEditorProps = ComponentProps; + + const setup = (props: Partial) => { + return render( + + + + + + + + + + + + ); + }; + + describe('WHEN a nested field edit button is clicked', () => { + it('SHOULD open a flyout with the correct field to edit', async () => { + const defaultMappings = { + properties: { + user: { + type: 'object', + properties: { + street: { type: 'text' }, + }, + }, + }, + }; + + setup({ value: defaultMappings, onChange: onChangeHandler, indexSettings: {} }); + + const userField = await screen.findByTestId('fieldsListItem userField'); + fireEvent.click(within(userField).getByTestId('toggleExpandButton')); + + const streetListItem = await screen.findByTestId('fieldsListItem userstreetField'); + + fireEvent.click(within(streetListItem).getByTestId('editFieldButton')); + + const flyout = await screen.findByTestId('mappingsEditorFieldEdit'); + + const flyoutTitle = within(flyout).getByTestId('flyoutTitle'); + expect(flyoutTitle.textContent).toEqual(`Edit field 'street'`); + + const fieldPath = within(flyout).getByTestId('fieldPath'); + expect(fieldPath.textContent).toEqual('user > street'); + }); + }); + + describe('WHEN the field datatype is changed', () => { + it('SHOULD update form parameters accordingly', async () => { + const defaultMappings = { + properties: { + userName: { + ...defaultTextParameters, + }, + }, + }; + + setup({ value: defaultMappings, onChange: onChangeHandler, indexSettings: {} }); + + await screen.findByTestId('mappingsEditor'); + await screen.findByTestId('fieldsList'); + + const userNameListItem = getFieldListItemById('userName'); + expect(userNameListItem).toBeInTheDocument(); + + fireEvent.click(within(userNameListItem).getByTestId('editFieldButton')); + + const flyout = await screen.findByTestId('mappingsEditorFieldEdit'); + + const fieldTypeSelect = within(flyout).getByTestId('fieldType'); + fireEvent.change(fieldTypeSelect, { target: { value: 'range' } }); + fireEvent.blur(fieldTypeSelect); + + await within(flyout).findByTestId('fieldSubType'); + + const updateButton = within(flyout).getByTestId('editFieldUpdateButton'); + await waitFor(() => { + expect(updateButton).not.toBeDisabled(); + }); + fireEvent.click(updateButton); + + await waitFor(() => { + expect(screen.queryByTestId('mappingsEditorFieldEdit')).not.toBeInTheDocument(); + }); + + await waitFor(() => { + const lastCall = onChangeHandler.mock.calls[onChangeHandler.mock.calls.length - 1][0]; + const data = lastCall.getData(lastCall.isValid ?? true); + + const updatedMappings = { + ...defaultMappings, + properties: { + userName: { + ...defaultDateRangeParameters, + }, + }, + }; + + expect(data).toEqual(updatedMappings); + }); + }); + }); + + describe('WHEN the field editor is opened without changes', () => { + it('SHOULD have the Update button disabled until changes are made', async () => { + const defaultMappings = { + properties: { + myField: { + type: 'text', + }, + }, + }; + + setup({ value: defaultMappings, onChange: onChangeHandler, indexSettings: {} }); + + await screen.findByTestId('mappingsEditor'); + await screen.findByTestId('fieldsList'); + + const myFieldListItem = getFieldListItemById('myField'); + + fireEvent.click(within(myFieldListItem).getByTestId('editFieldButton')); + + const flyout = await screen.findByTestId('mappingsEditorFieldEdit'); + + const updateButton = within(flyout).getByTestId('editFieldUpdateButton'); + expect(updateButton).toBeDisabled(); + + fireEvent.change(within(flyout).getByTestId('nameParameterInput'), { + target: { value: 'updatedField' }, + }); + expect(updateButton).not.toBeDisabled(); + }); + }); +}); diff --git a/x-pack/platform/plugins/shared/index_management/public/application/components/mappings_editor/mappings_editor.test.tsx b/x-pack/platform/plugins/shared/index_management/public/application/components/mappings_editor/mappings_editor.test.tsx index 1c2ad2a698113..63d7f522bd967 100644 --- a/x-pack/platform/plugins/shared/index_management/public/application/components/mappings_editor/mappings_editor.test.tsx +++ b/x-pack/platform/plugins/shared/index_management/public/application/components/mappings_editor/mappings_editor.test.tsx @@ -9,20 +9,138 @@ import React from 'react'; import type { ComponentProps } from 'react'; import { render, screen, within, fireEvent, waitFor } from '@testing-library/react'; import { I18nProvider } from '@kbn/i18n-react'; +import SemVer from 'semver/classes/semver'; +import { docLinksServiceMock, uiSettingsServiceMock } from '@kbn/core/public/mocks'; +import { GlobalFlyout } from '@kbn/es-ui-shared-plugin/public'; import { defaultInferenceEndpoints } from '@kbn/inference-common'; -import { EuiComboBoxTestHarness, EuiSuperSelectTestHarness } from '@kbn/test-eui-helpers'; -import { WithAppDependencies } from './__jest__/client_integration/helpers/setup_environment'; +import { MAJOR_VERSION } from '../../../../common'; +import { useAppContext } from '../../app_context'; import { MappingsEditor } from './mappings_editor'; -import { TYPE_DEFINITION } from './constants'; -import type { AppDependencies } from '../..'; +import { MappingsEditorProvider } from './mappings_editor_context'; +import { createKibanaReactContext } from './shared_imports'; +import { UseField } from './shared_imports'; +import { getFieldConfig } from './lib'; + +jest.mock('@kbn/code-editor'); + +jest.mock('./components/document_fields/field_parameters/type_parameter', () => { + const sharedImports = jest.requireActual('./shared_imports'); + const lib = jest.requireActual('./lib'); + const UseFieldActual = sharedImports.UseField as typeof UseField; + const getFieldConfigActual = lib.getFieldConfig as typeof getFieldConfig; + + const options = [ + { value: 'text', label: 'text' }, + { value: 'semantic_text', label: 'semantic_text' }, + { value: 'other', label: 'other' }, + { value: 'range', label: 'range' }, + { value: 'date_range', label: 'date_range' }, + ]; + + const TypeParameter = () => ( + + {(field: unknown) => { + const f = field as { + value?: Array<{ value?: string }>; + setValue: (value: unknown) => void; + }; + + return ( + + ); + }} + + ); + + return { __esModule: true, TypeParameter }; +}); + +jest.mock('./components/document_fields/field_parameters/reference_field_selects', () => { + const sharedImports = jest.requireActual('./shared_imports'); + const lib = jest.requireActual('./lib'); + const UseFieldActual = sharedImports.UseField as typeof UseField; + const getFieldConfigActual = lib.getFieldConfig as typeof getFieldConfig; + + const ReferenceFieldSelects = () => ( + + {(field: unknown) => { + const f = field as { value?: string; setValue: (value: unknown) => void }; + return ( +
+ +
+ ); + }} +
+ ); + + return { __esModule: true, ReferenceFieldSelects }; +}); + +jest.mock('../../app_context', () => { + const actual = jest.requireActual('../../app_context'); + return { + ...actual, + useAppContext: jest.fn(), + }; +}); -// Helper to map type values to their display labels -const getTypeLabel = (typeValue: string): string => { - const typeDef = TYPE_DEFINITION[typeValue as keyof typeof TYPE_DEFINITION]; - return typeDef?.label || typeValue; +interface MockSelectInferenceIdContentProps { + dataTestSubj?: string; + value: string; + setValue: (value: string) => void; +} + +const MockSelectInferenceIdContent: React.FC = ({ + dataTestSubj, + value, + setValue, +}) => { + React.useEffect(() => { + if (!value) setValue(defaultInferenceEndpoints.ELSER); + }, [value, setValue]); + + return
; }; +function mockSelectInferenceId({ 'data-test-subj': dataTestSubj }: { 'data-test-subj'?: string }) { + const config = getFieldConfig('inference_id'); + return ( + + {(field) => ( + + )} + + ); +} + +jest.mock('./components/document_fields/field_parameters/select_inference_id', () => ({ + SelectInferenceId: mockSelectInferenceId, +})); + jest.mock('../component_templates/component_templates_context', () => ({ useComponentTemplatesContext: jest.fn().mockReturnValue({ toasts: { @@ -32,12 +150,58 @@ jest.mock('../component_templates/component_templates_context', () => ({ }), })); +const { GlobalFlyoutProvider } = GlobalFlyout; +const mockUseAppContext = useAppContext as unknown as jest.MockedFunction; +const docLinks = docLinksServiceMock.createStartContract(); +const kibanaVersion = new SemVer(MAJOR_VERSION); +const { Provider: KibanaReactContextProvider } = createKibanaReactContext({ + uiSettings: uiSettingsServiceMock.createSetupContract(), + kibanaVersion: { + get: () => kibanaVersion, + }, +}); +const defaultAppContext = { + config: { enableMappingsSourceFieldSection: true }, + canUseSyntheticSource: true, +}; + +type MappingsEditorTestProps = Omit< + ComponentProps, + 'docLinks' | 'esNodesPlugins' +>; + +const renderMappingsEditor = ( + props: Partial, + ctx: unknown = defaultAppContext +) => { + mockUseAppContext.mockReturnValue(ctx as unknown as ReturnType); + const { onChange, ...restProps } = props; + const mergedProps = { + ...restProps, + docLinks, + esNodesPlugins: [], + onChange: onChange ?? (() => undefined), + } satisfies ComponentProps; + return render( + + + + + + + + + + ); +}; + describe('Mappings editor', () => { describe('core', () => { interface TestMappings { dynamic?: boolean; numeric_detection?: boolean; date_detection?: boolean; + dynamic_date_formats?: unknown; properties?: Record>; dynamic_templates?: unknown[]; _source?: { enabled?: boolean; includes?: string[]; excludes?: string[] }; @@ -49,32 +213,16 @@ describe('Mappings editor', () => { let data: TestMappings | undefined; let onChangeHandler: jest.Mock = jest.fn(); - const appDependencies = { - core: { application: {}, http: {} }, - services: { - notificationService: { toasts: {} }, - }, - docLinks: { - links: { - inferenceManagement: { - inferenceAPIDocumentation: 'https://abc.com/inference-api-create', - }, - }, - }, - plugins: { - ml: { mlApi: {} }, - }, - } as unknown as Partial; - type MappingsEditorProps = ComponentProps; - const setup = (props: Partial, ctx: unknown = appDependencies) => { - const Component = WithAppDependencies(MappingsEditor, ctx); - return render( - - - - ); + const setup = ( + props: Partial, + ctx: unknown = { + config: { enableMappingsSourceFieldSection: true }, + canUseSyntheticSource: true, + } + ) => { + return renderMappingsEditor({ onChange: onChangeHandler, ...props }, ctx); }; const selectTab = async (tabName: string) => { @@ -99,11 +247,10 @@ describe('Mappings editor', () => { const nameInput = screen.getByTestId('nameParameterInput'); fireEvent.change(nameInput, { target: { value: name } }); - // Select type using EuiComboBox harness - use label, not value - const typeComboBox = new EuiComboBoxTestHarness('fieldType'); - await typeComboBox.select(getTypeLabel(type)); - // Close the combobox popover (portal) so it can't intercept later clicks/keystrokes. - await typeComboBox.close(); + // Select type using a lightweight mock (avoid EuiComboBox portal cost) + const typeSelect = screen.getByTestId('fieldType'); + fireEvent.change(typeSelect, { target: { value: type } }); + fireEvent.blur(typeSelect); if (subType !== undefined && type === 'other') { const subTypeInput = await screen.findByTestId('fieldSubType'); @@ -112,37 +259,16 @@ describe('Mappings editor', () => { if (referenceField !== undefined) { // Wait for reference field to appear after semantic_text type is selected - await screen.findByTestId('referenceFieldSelect'); - - const referenceSelect = new EuiSuperSelectTestHarness('referenceFieldSelect'); - await referenceSelect.select(`select-reference-field-${referenceField}`); + const referenceSelect = await screen.findByTestId('referenceFieldSelectInput'); + fireEvent.change(referenceSelect, { target: { value: referenceField } }); + fireEvent.blur(referenceSelect); } const addButton = screen.getByTestId('addButton'); fireEvent.click(addButton); - await waitFor(() => { - const documentFields = screen.getByTestId('documentFields'); - const listItems = within(documentFields).getAllByTestId((content) => - content.startsWith('fieldsListItem ') - ); - const found = listItems.some((it) => { - const fieldNameEls = within(it).queryAllByTestId(/fieldName/); - return fieldNameEls.some((el) => { - if ((el.textContent || '').trim() !== name) return false; - - let node: HTMLElement | null = el as HTMLElement; - while (node && node !== it) { - const subj = node.getAttribute('data-test-subj'); - if (typeof subj === 'string' && subj.startsWith('fieldsListItem ')) return false; - node = node.parentElement; - } - - return true; - }); - }); - expect(found).toBe(true); - }); + // Root-level fields use data-test-subj `fieldsListItem ${name}Field` + await screen.findByTestId(`fieldsListItem ${name}Field`); const cancelButton = await screen.findByTestId('cancelButton'); fireEvent.click(cancelButton); @@ -313,9 +439,9 @@ describe('Mappings editor', () => { const ctx = { config: { - enableMappingsSourceFieldSection: true, + enableMappingsSourceFieldSection: false, }, - ...appDependencies, + canUseSyntheticSource: false, }; test('should have 4 tabs (fields, runtime, template, advanced settings)', async () => { @@ -333,129 +459,67 @@ describe('Mappings editor', () => { ]); }); - test('should keep the changes when switching tabs', async () => { + const openCreateFieldForm = async () => { + const addFieldButton = screen.getByTestId('addFieldButton'); + fireEvent.click(addFieldButton); + await screen.findByTestId('createFieldForm'); + }; + + test('keeps mapped fields when switching tabs', async () => { setup({ value: defaultMappings, onChange: onChangeHandler }, ctx); await screen.findByTestId('mappingsEditor'); // Start with empty fields list - const fieldsListItems = screen.queryAllByTestId('fieldsListItem'); - expect(fieldsListItems).toHaveLength(0); - - // Add a new field - const addFieldButton = screen.getByTestId('addFieldButton'); - fireEvent.click(addFieldButton); - - await screen.findByTestId('createFieldForm'); + expect(screen.queryByTestId('fieldsListItem JohnField')).not.toBeInTheDocument(); + await openCreateFieldForm(); const newField = { name: 'John', type: 'text' }; await addField(newField.name, newField.type); - // Verify field was added - await waitFor(() => { - const items = screen.getAllByTestId(/^fieldsListItem/); - expect(items).toHaveLength(1); - }); + // Switch away and back + await selectTab('templates'); + await selectTab('fields'); + expect(await screen.findByTestId('fieldsListItem JohnField')).toBeInTheDocument(); + }); - await waitFor(() => { - const documentFields = screen.getByTestId('documentFields'); - const listItems = within(documentFields).getAllByTestId((content) => - content.startsWith('fieldsListItem ') - ); - const found = listItems.some((it) => { - const fieldNameEls = within(it).queryAllByTestId(/fieldName/); - return fieldNameEls.some((el) => { - if ((el.textContent || '').trim() !== newField.name) return false; - - let node: HTMLElement | null = el as HTMLElement; - while (node && node !== it) { - const subj = node.getAttribute('data-test-subj'); - if (typeof subj === 'string' && subj.startsWith('fieldsListItem ')) return false; - node = node.parentElement; - } - - return true; - }); - }); - expect(found).toBe(true); - }); + test('keeps dynamic templates edits when switching tabs', async () => { + setup({ value: defaultMappings, onChange: onChangeHandler }, ctx); + await screen.findByTestId('mappingsEditor'); - // Navigate to dynamic templates tab await selectTab('templates'); - let templatesValue = getJsonEditorValue('dynamicTemplatesEditor'); - expect(templatesValue).toEqual(defaultMappings.dynamic_templates); - - // Update the dynamic templates editor value const updatedValueTemplates = [{ after: 'bar' }]; updateJsonEditor('dynamicTemplatesEditor', updatedValueTemplates); + expect(getJsonEditorValue('dynamicTemplatesEditor')).toEqual(updatedValueTemplates); - templatesValue = getJsonEditorValue('dynamicTemplatesEditor'); - expect(templatesValue).toEqual(updatedValueTemplates); - - // Switch to advanced settings tab and make some changes - await selectTab('advanced'); - - let isDynamicMappingsEnabled = getToggleValue( - 'advancedConfiguration.dynamicMappingsToggle.input' - ); - expect(isDynamicMappingsEnabled).toBe(true); - - let isNumericDetectionVisible = screen.queryByTestId('numericDetection'); - expect(isNumericDetectionVisible).toBeInTheDocument(); - - // Turn off dynamic mappings - await toggleEuiSwitch('advancedConfiguration.dynamicMappingsToggle.input'); + // Switch to a lightweight tab and back (avoid rendering advanced options) + await selectTab('fields'); + await selectTab('templates'); - isDynamicMappingsEnabled = getToggleValue( - 'advancedConfiguration.dynamicMappingsToggle.input' - ); - expect(isDynamicMappingsEnabled).toBe(false); + expect(getJsonEditorValue('dynamicTemplatesEditor')).toEqual(updatedValueTemplates); + }); - isNumericDetectionVisible = screen.queryByTestId('numericDetection'); - expect(isNumericDetectionVisible).not.toBeInTheDocument(); + test('keeps advanced settings edits when switching tabs', async () => { + setup({ value: defaultMappings, onChange: onChangeHandler }, ctx); + await screen.findByTestId('mappingsEditor'); - // Go back to dynamic templates tab and make sure our changes are still there - await selectTab('templates'); + await selectTab('advanced'); - templatesValue = getJsonEditorValue('dynamicTemplatesEditor'); - expect(templatesValue).toEqual(updatedValueTemplates); + expect(getToggleValue('advancedConfiguration.dynamicMappingsToggle.input')).toBe(true); + expect(screen.queryByTestId('numericDetection')).toBeInTheDocument(); - // Go back to fields and make sure our created field is there - await selectTab('fields'); + await toggleEuiSwitch('advancedConfiguration.dynamicMappingsToggle.input'); - await waitFor(() => { - const documentFields = screen.getByTestId('documentFields'); - const listItems = within(documentFields).getAllByTestId((content) => - content.startsWith('fieldsListItem ') - ); - const found = listItems.some((it) => { - const fieldNameEls = within(it).queryAllByTestId(/fieldName/); - return fieldNameEls.some((el) => { - if ((el.textContent || '').trim() !== newField.name) return false; - - let node: HTMLElement | null = el as HTMLElement; - while (node && node !== it) { - const subj = node.getAttribute('data-test-subj'); - if (typeof subj === 'string' && subj.startsWith('fieldsListItem ')) return false; - node = node.parentElement; - } - - return true; - }); - }); - expect(found).toBe(true); - }); + expect(getToggleValue('advancedConfiguration.dynamicMappingsToggle.input')).toBe(false); + expect(screen.queryByTestId('numericDetection')).not.toBeInTheDocument(); - // Go back to advanced settings tab make sure dynamic mappings is disabled + // Switch to a lightweight tab and back (avoid JSON editor work) + await selectTab('runtimeFields'); await selectTab('advanced'); - isDynamicMappingsEnabled = getToggleValue( - 'advancedConfiguration.dynamicMappingsToggle.input' - ); - expect(isDynamicMappingsEnabled).toBe(false); - isNumericDetectionVisible = screen.queryByTestId('numericDetection'); - expect(isNumericDetectionVisible).not.toBeInTheDocument(); - }, 10000); + expect(getToggleValue('advancedConfiguration.dynamicMappingsToggle.input')).toBe(false); + expect(screen.queryByTestId('numericDetection')).not.toBeInTheDocument(); + }); test('should keep default dynamic templates value when switching tabs', async () => { setup( @@ -496,7 +560,6 @@ describe('Mappings editor', () => { enableMappingsSourceFieldSection: true, }, canUseSyntheticSource: true, - ...appDependencies, }; beforeEach(() => { @@ -530,8 +593,7 @@ describe('Mappings editor', () => { }; }); - // FLAKY: https://github.com/elastic/kibana/issues/254951 - describe.skip('props.value and props.onChange', () => { + describe('props.value and props.onChange', () => { beforeEach(async () => { setup({ value: defaultMappings, onChange: onChangeHandler }, ctx); await screen.findByTestId('mappingsEditor'); @@ -580,22 +642,18 @@ describe('Mappings editor', () => { expect(isRoutingRequired).toBe(defaultMappings._routing!.required); }); - test('props.onChange() => should forward the changes to the consumer component', async () => { - let updatedMappings = { ...defaultMappings }; - - // Mapped fields + test('props.onChange() => forwards mapped field changes', async () => { const addFieldButton = screen.getByTestId('addFieldButton'); fireEvent.click(addFieldButton); - await screen.findByTestId('createFieldForm'); const newField = { name: 'someNewField', type: 'text' }; await addField(newField.name, newField.type); - updatedMappings = { - ...updatedMappings, + const expectedMappings = { + ...defaultMappings, properties: { - ...updatedMappings.properties, + ...defaultMappings.properties, [newField.name]: { type: 'text' }, }, }; @@ -604,51 +662,52 @@ describe('Mappings editor', () => { expect(onChangeHandler).toHaveBeenCalled(); const lastCall = onChangeHandler.mock.calls[onChangeHandler.mock.calls.length - 1][0]; data = lastCall.getData(lastCall.isValid ?? true); - expect(data).toEqual(updatedMappings); + expect(data).toEqual(expectedMappings); }); + }); - // Dynamic templates + test('props.onChange() => forwards dynamic templates changes', async () => { await selectTab('templates'); const updatedTemplatesValue = [{ someTemplateProp: 'updated' }]; - updatedMappings = { - ...updatedMappings, + updateJsonEditor('dynamicTemplatesEditor', updatedTemplatesValue); + + const expectedMappings = { + ...defaultMappings, dynamic_templates: updatedTemplatesValue, }; - updateJsonEditor('dynamicTemplatesEditor', updatedTemplatesValue); - await waitFor(() => { + expect(onChangeHandler).toHaveBeenCalled(); const lastCall = onChangeHandler.mock.calls[onChangeHandler.mock.calls.length - 1][0]; data = lastCall.getData(lastCall.isValid ?? true); - expect(data).toEqual(updatedMappings); + expect(data).toEqual(expectedMappings); }); + }); - // Advanced settings + test('props.onChange() => forwards advanced settings changes', async () => { await selectTab('advanced'); - // Disable dynamic mappings await toggleEuiSwitch('advancedConfiguration.dynamicMappingsToggle.input'); + const expectedMappings = { + ...defaultMappings, + dynamic: false, + // The "enabled": true is removed as this is the default in Es + _source: { + includes: defaultMappings._source!.includes, + excludes: defaultMappings._source!.excludes, + }, + }; + delete expectedMappings.date_detection; + delete expectedMappings.dynamic_date_formats; + delete expectedMappings.numeric_detection; + await waitFor(() => { + expect(onChangeHandler).toHaveBeenCalled(); const lastCall = onChangeHandler.mock.calls[onChangeHandler.mock.calls.length - 1][0]; data = lastCall.getData(lastCall.isValid ?? true); - - // When we disable dynamic mappings, we set it to "false" and remove date and numeric detections - updatedMappings = { - ...updatedMappings, - dynamic: false, - // The "enabled": true is removed as this is the default in Es - _source: { - includes: defaultMappings._source!.includes, - excludes: defaultMappings._source!.excludes, - }, - }; - delete updatedMappings.date_detection; - delete updatedMappings.dynamic_date_formats; - delete updatedMappings.numeric_detection; - - expect(data).toEqual(updatedMappings); + expect(data).toEqual(expectedMappings); }); }); }); // Close inner describe for props.value and props.onChange @@ -706,46 +765,7 @@ describe('Mappings editor', () => { referenceField: 'title', }; - // Start adding the semantic_text field - const nameInput = screen.getByTestId('nameParameterInput'); - fireEvent.change(nameInput, { target: { value: newField.name } }); - - // Select semantic_text type using EuiComboBox harness - const typeComboBox = new EuiComboBoxTestHarness('fieldType'); - await typeComboBox.select(getTypeLabel(newField.type)); - await typeComboBox.close(); - - // Wait for reference field selector to appear with the address.city option - await screen.findByTestId('referenceFieldSelect'); - - const referenceSelect = new EuiSuperSelectTestHarness('referenceFieldSelect'); - await referenceSelect.select(`select-reference-field-${newField.referenceField}`); - - const addButton = screen.getByTestId('addButton'); - fireEvent.click(addButton); - - await waitFor(() => { - const documentFields = screen.getByTestId('documentFields'); - const listItems = within(documentFields).getAllByTestId((content) => - content.startsWith('fieldsListItem ') - ); - const found = listItems.some((it) => { - const fieldNameEls = within(it).queryAllByTestId(/fieldName/); - return fieldNameEls.some((el) => { - if ((el.textContent || '').trim() !== newField.name) return false; - - let node: HTMLElement | null = el as HTMLElement; - while (node && node !== it) { - const subj = node.getAttribute('data-test-subj'); - if (typeof subj === 'string' && subj.startsWith('fieldsListItem ')) return false; - node = node.parentElement; - } - - return true; - }); - }); - expect(found).toBe(true); - }); + await addField(newField.name, newField.type, undefined, newField.referenceField); updatedMappings = { ...updatedMappings, @@ -765,7 +785,7 @@ describe('Mappings editor', () => { data = lastCall.getData(lastCall.isValid ?? true); expect(data).toEqual(updatedMappings); }); - }, 6500); + }); }); describe('props.indexMode sets the correct default value of _source field', () => { @@ -882,12 +902,7 @@ describe('Mappings editor', () => { const onChangeHandler = jest.fn(); test('allow to add custom field type', async () => { - const Component = WithAppDependencies(MappingsEditor, {}); - render( - - - - ); + renderMappingsEditor({ onChange: onChangeHandler, indexSettings: {} }); await screen.findByTestId('mappingsEditor'); @@ -901,14 +916,12 @@ describe('Mappings editor', () => { const nameInput = within(createForm).getByTestId('nameParameterInput'); fireEvent.change(nameInput, { target: { value: 'myField' } }); - // Select "other" field type using EuiComboBox harness - const fieldTypeComboBox = new EuiComboBoxTestHarness('fieldType'); - await fieldTypeComboBox.select('other'); - await fieldTypeComboBox.close(); + // Select "other" field type using the lightweight TypeParameter mock + const fieldTypeSelect = within(createForm).getByTestId('fieldType'); + fireEvent.change(fieldTypeSelect, { target: { value: 'other' } }); + fireEvent.blur(fieldTypeSelect); - await waitFor(() => { - expect(within(createForm).queryByTestId('fieldSubType')).toBeInTheDocument(); - }); + await within(createForm).findByTestId('fieldSubType'); const customTypeInput = within(createForm).getByTestId('fieldSubType'); fireEvent.change(customTypeInput, { target: { value: 'customType' } }); @@ -951,12 +964,11 @@ describe('Mappings editor', () => { }, }; - const Component = WithAppDependencies(MappingsEditor, {}); - render( - - - - ); + renderMappingsEditor({ + value: defaultMappings, + onChange: onChangeHandler, + indexSettings: {}, + }); await screen.findByTestId('mappingsEditor'); @@ -966,10 +978,10 @@ describe('Mappings editor', () => { const flyout = await screen.findByTestId('mappingsEditorFieldEdit'); - // Change the field type to "other" using EuiComboBox harness - const fieldTypeComboBox = new EuiComboBoxTestHarness('fieldType'); - await fieldTypeComboBox.select('other'); - await fieldTypeComboBox.close(); + // Change the field type to "other" using the lightweight TypeParameter mock + const fieldTypeSelect = within(flyout).getByTestId('fieldType'); + fireEvent.change(fieldTypeSelect, { target: { value: 'other' } }); + fireEvent.blur(fieldTypeSelect); const customTypeInput = await within(flyout).findByTestId('fieldSubType'); fireEvent.change(customTypeInput, { target: { value: 'customType' } }); diff --git a/x-pack/platform/plugins/shared/index_management/public/application/components/mappings_editor/mappings_editor.text_datatype.test.tsx b/x-pack/platform/plugins/shared/index_management/public/application/components/mappings_editor/mappings_editor.text_datatype.test.tsx new file mode 100644 index 0000000000000..a349092e3a579 --- /dev/null +++ b/x-pack/platform/plugins/shared/index_management/public/application/components/mappings_editor/mappings_editor.text_datatype.test.tsx @@ -0,0 +1,508 @@ +/* + * 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 React from 'react'; +import { render, screen, fireEvent, waitFor, within, act } from '@testing-library/react'; +import { I18nProvider } from '@kbn/i18n-react'; +import SemVer from 'semver/classes/semver'; +import { docLinksServiceMock, uiSettingsServiceMock } from '@kbn/core/public/mocks'; +import { GlobalFlyout } from '@kbn/es-ui-shared-plugin/public'; + +import { MAJOR_VERSION } from '../../../../common'; +import { useAppContext } from '../../app_context'; +import { MappingsEditor } from './mappings_editor'; +import { MappingsEditorProvider } from './mappings_editor_context'; +import { createKibanaReactContext } from './shared_imports'; +import { getFieldConfig } from './lib'; + +jest.mock('@kbn/code-editor'); + +type UseFieldType = typeof import('./shared_imports').UseField; +type GetFieldConfigType = typeof import('./lib').getFieldConfig; + +jest.mock('./components/document_fields/field_parameters/type_parameter', () => { + const sharedImports = jest.requireActual('./shared_imports'); + const lib = jest.requireActual('./lib'); + const UseFieldActual = sharedImports.UseField as UseFieldType; + const getFieldConfigActual = lib.getFieldConfig as GetFieldConfigType; + + const options = [ + { value: 'text', label: 'text' }, + { value: 'semantic_text', label: 'semantic_text' }, + { value: 'other', label: 'other' }, + ]; + + const TypeParameter = () => ( + > + path="type" + config={getFieldConfigActual>('type')} + > + {(field) => { + return ( + + ); + }} + + ); + + return { __esModule: true, TypeParameter }; +}); + +jest.mock('@elastic/eui', () => { + const actual = jest.requireActual('@elastic/eui'); + + return { + ...actual, + EuiPortal: ({ children }: { children?: React.ReactNode }) => <>{children}, + EuiOverlayMask: ({ children }: { children?: React.ReactNode }) => <>{children}, + EuiSuperSelect: ({ + options, + valueOfSelected, + onChange, + 'data-test-subj': dataTestSubj, + }: { + options: Array<{ + value: string; + inputDisplay?: import('react').ReactNode; + dropdownDisplay?: import('react').ReactNode; + }>; + valueOfSelected: string; + onChange: (value: string) => void; + 'data-test-subj'?: string; + }) => ( + + ), + }; +}); + +jest.mock('../../app_context', () => { + const actual = jest.requireActual('../../app_context'); + return { + ...actual, + useAppContext: jest.fn(), + }; +}); + +const { GlobalFlyoutProvider } = GlobalFlyout; +const mockUseAppContext = useAppContext as unknown as jest.MockedFunction; +const docLinks = docLinksServiceMock.createStartContract(); +const kibanaVersion = new SemVer(MAJOR_VERSION); +const { Provider: KibanaReactContextProvider } = createKibanaReactContext({ + uiSettings: uiSettingsServiceMock.createSetupContract(), + kibanaVersion: { + get: () => kibanaVersion, + }, +}); + +const defaultTextParameters = { + type: 'text', + eager_global_ordinals: false, + fielddata: false, + index: true, + index_options: 'positions', + index_phrases: false, + norms: true, + store: false, +}; + +const onChangeHandler = jest.fn(); + +interface TestMappings { + properties: Record>; + _meta?: Record; + _source?: Record; +} + +const openFieldEditor = async () => { + const listItem = screen.getByTestId('fieldsListItem myFieldField'); + const editButton = within(listItem).getByTestId('editFieldButton'); + fireEvent.click(editButton); + return screen.findByTestId('mappingsEditorFieldEdit'); +}; + +const toggleAdvancedSettings = async (flyout: HTMLElement) => { + const advancedToggle = within(flyout).getByTestId('toggleAdvancedSetting'); + fireEvent.click(advancedToggle); + await waitFor(() => { + const advancedSettings = within(flyout).getByTestId('advancedSettings'); + expect(advancedSettings.style.display).not.toBe('none'); + }); +}; + +const updateFieldName = (flyout: HTMLElement, name: string) => { + const nameInput = within(flyout).getByTestId('nameParameterInput'); + fireEvent.change(nameInput, { target: { value: name } }); +}; + +const submitForm = async (flyout: HTMLElement) => { + const updateButton = within(flyout).getByTestId('editFieldUpdateButton'); + fireEvent.click(updateButton); + await waitFor(() => { + expect(onChangeHandler).toHaveBeenCalled(); + }); +}; + +const getLatestMappings = () => { + const [callData] = onChangeHandler.mock.calls[onChangeHandler.mock.calls.length - 1]; + return callData.getData(); +}; + +const setAnalyzerValue = async (flyout: HTMLElement, rowTestSubj: string, value: string) => { + const rows = within(flyout).getAllByTestId(rowTestSubj); + const row = + rows.find((r) => within(r).queryByTestId('select') !== null) ?? + rows.find((r) => r.querySelector('select') !== null) ?? + rows[0]; + + const select = within(row).getByTestId('select') as HTMLSelectElement; + + await act(async () => { + fireEvent.change(select, { target: { value } }); + fireEvent.blur(select); + }); +}; + +const toggleUseSameSearchAnalyzer = (flyout: HTMLElement) => { + fireEvent.click(within(flyout).getByRole('checkbox')); +}; + +type MappingsEditorTestProps = Omit< + React.ComponentProps, + 'docLinks' | 'esNodesPlugins' +>; + +const renderMappingsEditor = (props: Partial) => { + const { onChange, ...restProps } = props; + const mergedProps: React.ComponentProps = { + ...restProps, + docLinks, + esNodesPlugins: [], + onChange: onChange ?? (() => undefined), + }; + + return render( + + + + + + + + + + + + ); +}; + +beforeEach(() => { + jest.clearAllMocks(); + mockUseAppContext.mockReturnValue({ + canUseSyntheticSource: true, + config: { enableMappingsSourceFieldSection: false }, + } as unknown as ReturnType); +}); + +describe('Mappings editor: text datatype', () => { + describe('WHEN a text field is opened for editing', () => { + it('SHOULD show default parameters values', async () => { + const defaultMappings = { + properties: { + myField: { + type: 'text', + }, + }, + }; + + renderMappingsEditor({ + value: defaultMappings, + onChange: onChangeHandler, + indexSettings: {}, + }); + + await screen.findByTestId('mappingsEditor'); + + const flyout = await openFieldEditor(); + + updateFieldName(flyout, 'updatedField'); + + // It should have searchable ("index" param) active by default + const indexFieldConfig = getFieldConfig('index'); + const indexParameterSection = within(flyout).getByTestId('indexParameter'); + const indexToggle = within(indexParameterSection).getByTestId('formRowToggle'); + expect(indexToggle.getAttribute('aria-checked')).toBe(String(indexFieldConfig.defaultValue)); + + if (kibanaVersion.major < 7) { + expect(within(flyout).queryByTestId('boostParameterToggle')).toBeInTheDocument(); + } else { + // Since 8.x the boost parameter is deprecated + expect(within(flyout).queryByTestId('boostParameterToggle')).not.toBeInTheDocument(); + } + + await submitForm(flyout); + + // It should have the default parameters values added + const updatedMappings = { + properties: { + updatedField: { + ...defaultTextParameters, + }, + }, + }; + + expect(getLatestMappings()).toEqual(updatedMappings); + }); + }); + + describe('WHEN editing analyzer parameters', () => { + const defaultMappingsWithAnalyzer = { + _meta: {}, + _source: {}, + properties: { + myField: { + type: 'text', + search_quote_analyzer: 'french', + }, + }, + }; + + afterEach(() => { + // Ensure flyout is closed after each test + const flyout = screen.queryByTestId('mappingsEditorFieldEdit'); + if (flyout) { + const closeButton = within(flyout).queryByTestId('euiFlyoutCloseButton'); + if (closeButton) { + fireEvent.click(closeButton); + } + } + }); + + it('SHOULD apply default values and show correct initial analyzers', async () => { + renderMappingsEditor({ + value: defaultMappingsWithAnalyzer, + onChange: onChangeHandler, + indexSettings: {}, + }); + + await screen.findByTestId('mappingsEditor'); + + const newFieldName = 'updatedField'; + + // Edit field, change name, and save to apply defaults + const flyout = await openFieldEditor(); + await toggleAdvancedSettings(flyout); + + // searchQuoteAnalyzer should show 'french' language (native select) + const allSelects = within(flyout).getAllByTestId('select'); + const frenchAnalyzerSelect = allSelects.find( + (el) => el.tagName === 'SELECT' && (el as HTMLSelectElement).value === 'french' + ) as HTMLSelectElement | undefined; + expect(frenchAnalyzerSelect).toBeDefined(); + + // "Use same analyzer for search" should be checked + expect(within(flyout).getByRole('checkbox')).toBeChecked(); + // searchAnalyzer should not exist when checkbox is checked + expect(within(flyout).queryByTestId('searchAnalyzer')).not.toBeInTheDocument(); + + updateFieldName(flyout, newFieldName); + await submitForm(flyout); + + // Verify default parameters were added + const updatedMappings = { + ...defaultMappingsWithAnalyzer, + properties: { + updatedField: { + ...defaultMappingsWithAnalyzer.properties.myField, + ...defaultTextParameters, + }, + }, + }; + + expect(getLatestMappings()).toEqual(updatedMappings); + }); + + // Checkbox toggle behavior is unit tested in analyzer_parameter.test.tsx + + it('SHOULD persist updated analyzer values after save', async () => { + renderMappingsEditor({ + value: defaultMappingsWithAnalyzer, + onChange: onChangeHandler, + indexSettings: {}, + }); + + await screen.findByTestId('mappingsEditor'); + + const flyout = await openFieldEditor(); + await toggleAdvancedSettings(flyout); + updateFieldName(flyout, 'updatedField'); + + // Change indexAnalyzer from default to 'standard' + await setAnalyzerValue(flyout, 'indexAnalyzer', 'standard'); + + // Uncheck "use same analyzer" to reveal searchAnalyzer + toggleUseSameSearchAnalyzer(flyout); + await waitFor(() => { + expect(within(flyout).queryByTestId('searchAnalyzer')).toBeInTheDocument(); + }); + + // Change searchAnalyzer to 'simple' + await setAnalyzerValue(flyout, 'searchAnalyzer', 'simple'); + + // Change searchQuoteAnalyzer from language (french) to built-in (whitespace) + await setAnalyzerValue(flyout, 'searchQuoteAnalyzer', 'whitespace'); + + await submitForm(flyout); + + const updatedMappings = { + ...defaultMappingsWithAnalyzer, + properties: { + updatedField: { + ...defaultMappingsWithAnalyzer.properties.myField, + ...defaultTextParameters, + analyzer: 'standard', + search_analyzer: 'simple', + search_quote_analyzer: 'whitespace', + }, + }, + }; + + expect(getLatestMappings()).toEqual(updatedMappings); + }); + + // Custom/built-in mode rendering and toggle behavior are unit tested in analyzer_parameter.test.tsx. + // This integration-style test verifies that custom analyzer changes serialize correctly through the full form pipeline. + it('SHOULD correctly serialize custom analyzer changes', async () => { + const defaultMappings: TestMappings = { + _meta: {}, + _source: {}, + properties: { + myField: { + type: 'text', + analyzer: 'myCustomIndexAnalyzer', + search_analyzer: 'myCustomSearchAnalyzer', + search_quote_analyzer: 'myCustomSearchQuoteAnalyzer', + }, + }, + }; + + renderMappingsEditor({ + value: defaultMappings, + onChange: onChangeHandler, + indexSettings: {}, + }); + + await screen.findByTestId('mappingsEditor'); + + const flyout = await openFieldEditor(); + await toggleAdvancedSettings(flyout); + + // Change index analyzer to a different custom value + const indexAnalyzerCustom = within(flyout).getByDisplayValue( + 'myCustomIndexAnalyzer' + ) as HTMLInputElement; + fireEvent.change(indexAnalyzerCustom, { target: { value: 'newCustomIndexAnalyzer' } }); + + // Toggle search analyzer from custom to built-in and select 'whitespace' + fireEvent.click(within(flyout).getByTestId('searchAnalyzer-toggleCustomButton')); + await waitFor(() => { + expect(within(flyout).queryByTestId('searchAnalyzer-custom')).not.toBeInTheDocument(); + }); + await setAnalyzerValue(flyout, 'searchAnalyzer', 'whitespace'); + + // Toggle searchQuote analyzer from custom to built-in (defaults to "index default") + fireEvent.click(within(flyout).getByTestId('searchQuoteAnalyzer-toggleCustomButton')); + await waitFor(() => + expect(within(flyout).getByTestId('searchQuoteAnalyzer')).toBeInTheDocument() + ); + + await submitForm(flyout); + + const expectedMappings: TestMappings = { + ...defaultMappings, + properties: { + myField: { + ...defaultMappings.properties.myField, + ...defaultTextParameters, + analyzer: 'newCustomIndexAnalyzer', + search_analyzer: 'whitespace', + search_quote_analyzer: undefined, + }, + }, + }; + + expect(getLatestMappings()).toEqual(expectedMappings); + }); + + // Index settings analyzer rendering is unit tested in analyzer_parameter.test.tsx. + it('SHOULD correctly serialize index settings analyzer changes', async () => { + const indexSettings = { + analysis: { + analyzer: { + customAnalyzer1: { type: 'custom', tokenizer: 'standard' }, + customAnalyzer2: { type: 'custom', tokenizer: 'standard' }, + customAnalyzer3: { type: 'custom', tokenizer: 'standard' }, + }, + }, + }; + + const defaultMappings: TestMappings = { + properties: { + myField: { type: 'text', analyzer: 'customAnalyzer1' }, + }, + }; + + renderMappingsEditor({ value: defaultMappings, onChange: onChangeHandler, indexSettings }); + + await screen.findByTestId('mappingsEditor'); + + const flyout = await openFieldEditor(); + await toggleAdvancedSettings(flyout); + + // Wait for the custom analyzer native select to appear, then change value + const customSelect = await waitFor(() => { + const el = within(flyout).getByDisplayValue('customAnalyzer1'); + expect(el.tagName).toBe('SELECT'); + return el as HTMLSelectElement; + }); + + fireEvent.change(customSelect, { target: { value: 'customAnalyzer3' } }); + + await submitForm(flyout); + + expect(getLatestMappings()).toEqual({ + properties: { + myField: { + ...defaultMappings.properties.myField, + ...defaultTextParameters, + analyzer: 'customAnalyzer3', + }, + }, + }); + }); + }); +}); diff --git a/x-pack/platform/plugins/shared/index_management/public/application/components/template_form/steps/step_components.test.tsx b/x-pack/platform/plugins/shared/index_management/public/application/components/template_form/steps/step_components.test.tsx new file mode 100644 index 0000000000000..7cc174455ef17 --- /dev/null +++ b/x-pack/platform/plugins/shared/index_management/public/application/components/template_form/steps/step_components.test.tsx @@ -0,0 +1,157 @@ +/* + * 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 React from 'react'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { I18nProvider } from '@kbn/i18n-react'; + +import type { ComponentTemplateListItem } from '../../../../../common'; +import { StepComponents } from './step_components'; + +const mockComponentTemplatesSelectorPropsSpy = jest.fn(); + +interface ComponentTemplatesSelectorMockProps { + defaultValue?: string[]; + onComponentsLoaded: (components: ComponentTemplateListItem[]) => void; + onChange: (components: string[]) => void; +} + +jest.mock('../../component_templates', () => ({ + __esModule: true, + ComponentTemplatesSelector: (props: ComponentTemplatesSelectorMockProps) => { + mockComponentTemplatesSelectorPropsSpy(props); + const { onComponentsLoaded, onChange, defaultValue } = props; + + return ( +
+
{JSON.stringify(defaultValue ?? null)}
+
+ ); + }, +})); + +describe('StepComponents', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockComponentTemplatesSelectorPropsSpy.mockClear(); + }); + + describe('WHEN components are loading', () => { + it('SHOULD show the header', () => { + render( + + + + ); + + expect(screen.getByTestId('stepComponents')).toBeInTheDocument(); + expect(screen.getByTestId('stepTitle')).toBeInTheDocument(); + expect(screen.getByTestId('mockComponentTemplatesSelector')).toBeInTheDocument(); + }); + }); + + describe('WHEN loaded components list is empty', () => { + it('SHOULD hide the header', () => { + render( + + + + ); + + fireEvent.click(screen.getByTestId('mockLoadComponentsEmpty')); + + expect(screen.queryByTestId('stepTitle')).not.toBeInTheDocument(); + }); + }); + + describe('WHEN loaded components list is non-empty', () => { + it('SHOULD show the header', () => { + render( + + + + ); + + fireEvent.click(screen.getByTestId('mockLoadComponentsNonEmpty')); + + expect(screen.getByTestId('stepTitle')).toBeInTheDocument(); + }); + }); + + describe('WHEN selection is empty', () => { + it('SHOULD emit wizard content with undefined data', () => { + const onChange = jest.fn(); + render( + + + + ); + + fireEvent.click(screen.getByTestId('mockSelectNone')); + + const content = onChange.mock.calls[0][0]; + expect(content.isValid).toBe(true); + expect(content.getData()).toBeUndefined(); + }); + }); + + describe('WHEN components are selected', () => { + it('SHOULD emit wizard content with the selected component names', () => { + const onChange = jest.fn(); + render( + + + + ); + + fireEvent.click(screen.getByTestId('mockSelectSome')); + + const content = onChange.mock.calls[0][0]; + expect(content.isValid).toBe(true); + expect(content.getData()).toEqual(['ct_1']); + }); + }); + + describe('WHEN defaultValue is provided', () => { + it('SHOULD forward it to ComponentTemplatesSelector', () => { + render( + + + + ); + + expect(screen.getByTestId('mockDefaultValue')).toHaveTextContent('component_template@custom'); + }); + }); +}); diff --git a/x-pack/platform/plugins/shared/index_management/public/application/components/template_form/steps/step_logistics.test.tsx b/x-pack/platform/plugins/shared/index_management/public/application/components/template_form/steps/step_logistics.test.tsx new file mode 100644 index 0000000000000..4ae98107fbfce --- /dev/null +++ b/x-pack/platform/plugins/shared/index_management/public/application/components/template_form/steps/step_logistics.test.tsx @@ -0,0 +1,64 @@ +/* + * 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 React from 'react'; +import { render, screen, within } from '@testing-library/react'; +import { I18nProvider } from '@kbn/i18n-react'; + +import { StepLogistics } from './step_logistics'; + +describe('StepLogistics', () => { + const baseDefaultValue = { + name: 'my_template', + indexPatterns: ['index-*'], + indexMode: 'standard', + allowAutoCreate: 'NO_OVERWRITE', + _meta: {}, + priority: 1, + version: 1, + dataStream: {}, + lifecycle: { enabled: false, value: 1, unit: 'd' }, + }; + + describe('WHEN editing an existing template', () => { + it('SHOULD disable the name field', async () => { + render( + + + + ); + + const nameRow = await screen.findByTestId('nameField'); + const nameInput = within(nameRow).getByRole('textbox'); + expect(nameInput).toBeDisabled(); + }); + }); + + describe('WHEN creating a new template', () => { + it('SHOULD enable the name field', async () => { + render( + + + + ); + + const nameRow = await screen.findByTestId('nameField'); + const nameInput = within(nameRow).getByRole('textbox'); + expect(nameInput).toBeEnabled(); + }); + }); +}); diff --git a/x-pack/platform/plugins/shared/index_management/public/application/components/template_form/steps/step_review.test.tsx b/x-pack/platform/plugins/shared/index_management/public/application/components/template_form/steps/step_review.test.tsx new file mode 100644 index 0000000000000..632d3f40b67b0 --- /dev/null +++ b/x-pack/platform/plugins/shared/index_management/public/application/components/template_form/steps/step_review.test.tsx @@ -0,0 +1,111 @@ +/* + * 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 React from 'react'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { I18nProvider } from '@kbn/i18n-react'; + +import type { TemplateDeserialized } from '../../../../../common'; +import { StepReview } from './step_review'; + +const mockSimulateTemplatePropsSpy = jest.fn(); +jest.mock('../../index_templates', () => ({ + __esModule: true, + SimulateTemplate: (props: unknown) => { + mockSimulateTemplatePropsSpy(props); + return
; + }, +})); + +describe('StepReview', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockSimulateTemplatePropsSpy.mockClear(); + }); + + const makeTemplate = (overrides: Partial = {}): TemplateDeserialized => ({ + name: 'my_template', + indexPatterns: ['index-*'], + indexMode: 'standard', + template: { + settings: { index: { number_of_shards: 1 } }, + mappings: { properties: { field_1: { type: 'keyword' } } }, + aliases: { my_alias: { is_write_index: true } }, + }, + composedOf: [], + ignoreMissingComponentTemplates: [], + allowAutoCreate: 'NO_OVERWRITE', + _kbnMeta: { type: 'default', hasDatastream: false, isLegacy: false }, + ...overrides, + }); + + describe('WHEN reviewing a composable template', () => { + it('SHOULD render Summary, Preview, and Request tabs', () => { + render( + + + + ); + + // EuiTabbedContent renders tab buttons; the preview tab exists for non-legacy. + expect(screen.getByText('Summary')).toBeInTheDocument(); + expect(screen.getByText('Preview')).toBeInTheDocument(); + expect(screen.getByText('Request')).toBeInTheDocument(); + }); + }); + + describe('WHEN reviewing a legacy template', () => { + it('SHOULD not render the Preview tab', () => { + render( + + + + ); + + expect(screen.getByText('Summary')).toBeInTheDocument(); + expect(screen.queryByText('Preview')).not.toBeInTheDocument(); + expect(screen.getByText('Request')).toBeInTheDocument(); + }); + }); + + describe('WHEN index patterns contain a wildcard', () => { + it('SHOULD show a warning and wire the edit link', () => { + const navigateToStep = jest.fn(); + render( + + + + ); + + expect(screen.getByTestId('indexPatternsWarning')).toBeInTheDocument(); + + fireEvent.click(screen.getByText('Edit index patterns.')); + expect(navigateToStep).toHaveBeenCalledWith('logistics', expect.any(Object)); + }); + }); + + describe('WHEN the Preview tab is clicked', () => { + it('SHOULD render the SimulateTemplate component', () => { + render( + + + + ); + + fireEvent.click(screen.getByText('Preview')); + expect(screen.getByTestId('mockSimulateTemplate')).toBeInTheDocument(); + }); + }); +}); diff --git a/x-pack/platform/plugins/shared/index_management/public/application/components/template_form/template_form.test.tsx b/x-pack/platform/plugins/shared/index_management/public/application/components/template_form/template_form.test.tsx new file mode 100644 index 0000000000000..b4ab7b8ceef5c --- /dev/null +++ b/x-pack/platform/plugins/shared/index_management/public/application/components/template_form/template_form.test.tsx @@ -0,0 +1,524 @@ +/* + * 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 React, { useEffect } from 'react'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { I18nProvider } from '@kbn/i18n-react'; + +import type { TemplateDeserialized } from '../../../../common'; +import { Forms, GlobalFlyout } from '../../../shared_imports'; +import type { WizardContent } from './template_form'; +import { TemplateForm } from './template_form'; + +jest.mock('@kbn/code-editor'); + +jest.mock('../../services/documentation', () => ({ + documentationService: { + getEsDocsBase: () => 'https://es-docs', + getTemplatesDocumentationLink: () => 'https://es-docs/templates', + getDataStreamsDocumentationLink: () => 'https://es-docs/data-streams', + }, +})); + +/** + * Lightweight step mocks that interact with the wizard's Forms.useContent() hook + * to feed data into the pipeline, without rendering real form components. + */ + +const MockStepLogistics = ({ + isEditing, + logisticsData, +}: { + isEditing?: boolean; + isLegacy?: boolean; + logisticsData: WizardContent['logistics']; +}) => { + const { updateContent } = Forms.useContent('logistics'); + + useEffect(() => { + updateContent({ + isValid: true, + validate: async () => true, + getData: () => logisticsData, + }); + }, [updateContent, logisticsData]); + + return ( +
+
{String(Boolean(isEditing))}
+
+ ); +}; + +const MockStepComponents = ({ + componentsData, +}: { + componentsData: WizardContent['components']; +}) => { + const { updateContent } = Forms.useContent('components'); + + useEffect(() => { + updateContent({ + isValid: true, + validate: async () => true, + getData: () => componentsData, + }); + }, [updateContent, componentsData]); + + return
; +}; + +const MockStepSettings = ({ settingsData }: { settingsData: WizardContent['settings'] }) => { + const { updateContent } = Forms.useContent('settings'); + + useEffect(() => { + updateContent({ + isValid: true, + validate: async () => true, + getData: () => settingsData, + }); + }, [updateContent, settingsData]); + + return
; +}; + +const MockStepMappings = ({ mappingsData }: { mappingsData: WizardContent['mappings'] }) => { + const { updateContent } = Forms.useContent('mappings'); + + useEffect(() => { + updateContent({ + isValid: true, + validate: async () => true, + getData: () => mappingsData, + }); + }, [updateContent, mappingsData]); + + return
; +}; + +const MockStepAliases = ({ aliasesData }: { aliasesData: WizardContent['aliases'] }) => { + const { updateContent } = Forms.useContent('aliases'); + + useEffect(() => { + updateContent({ + isValid: true, + validate: async () => true, + getData: () => aliasesData, + }); + }, [updateContent, aliasesData]); + + return
; +}; + +const MockStepReview = () => { + return
; +}; + +/** + * The mock step data that each step will contribute when navigated to. + * We store these in module scope so `jest.mock` factory functions can reference them. + */ +let mockLogisticsData: WizardContent['logistics']; +let mockComponentsData: WizardContent['components']; +let mockSettingsData: WizardContent['settings']; +let mockMappingsData: WizardContent['mappings']; +let mockAliasesData: WizardContent['aliases']; +let mockIsEditing: boolean | undefined; + +jest.mock('./steps', () => ({ + StepLogisticsContainer: (props: { isEditing?: boolean; isLegacy?: boolean }) => ( + + ), + StepComponentContainer: () => , + StepReviewContainer: () => , +})); + +jest.mock('../shared', () => ({ + StepSettingsContainer: () => , + StepMappingsContainer: () => , + StepAliasesContainer: () => , +})); + +jest.mock('../index_templates', () => ({ + SimulateTemplateFlyoutContent: () =>
, + simulateTemplateFlyoutProps: {}, + LegacyIndexTemplatesDeprecation: () => null, +})); + +const { GlobalFlyoutProvider } = GlobalFlyout; + +const renderTemplateForm = (props: Partial> = {}) => { + const defaultProps: React.ComponentProps = { + title: props.title ?? 'Test Form', + onSave: props.onSave ?? jest.fn(), + clearSaveError: props.clearSaveError ?? jest.fn(), + isSaving: props.isSaving ?? false, + saveError: props.saveError ?? null, + isEditing: props.isEditing, + isLegacy: props.isLegacy, + defaultValue: props.defaultValue, + }; + + return render( + + + + + + ); +}; + +const clickNextButton = () => { + fireEvent.click(screen.getByTestId('nextButton')); +}; + +describe('TemplateForm wizard integration', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockIsEditing = undefined; + }); + + describe('WHEN creating a new template', () => { + const settings = { index: { number_of_shards: 2 } }; + const mappings = { properties: { field_1: { type: 'text' } } }; + const aliases = { my_alias: { is_write_index: true } }; + + beforeEach(() => { + mockLogisticsData = { + name: 'new_template', + indexPatterns: ['index-*'], + dataStream: {}, + indexMode: 'standard', + allowAutoCreate: 'NO_OVERWRITE', + }; + mockComponentsData = ['component_1']; + mockSettingsData = settings; + mockMappingsData = mappings; + mockAliasesData = aliases; + }); + + it('SHOULD assemble correct payload through the full wizard flow', async () => { + const onSave = jest.fn(); + renderTemplateForm({ onSave }); + + // Step 1: Logistics + expect(screen.getByTestId('mockStepLogistics')).toBeInTheDocument(); + clickNextButton(); + + // Step 2: Component templates + await screen.findByTestId('mockStepComponents'); + clickNextButton(); + + // Step 3: Settings + await screen.findByTestId('mockStepSettings'); + clickNextButton(); + + // Step 4: Mappings + await screen.findByTestId('mockStepMappings'); + clickNextButton(); + + // Step 5: Aliases + await screen.findByTestId('mockStepAliases'); + clickNextButton(); + + // Step 6: Review → submit + await screen.findByTestId('mockStepReview'); + clickNextButton(); + + await waitFor(() => { + expect(onSave).toHaveBeenCalledTimes(1); + }); + + const savedTemplate = onSave.mock.calls[0][0] as TemplateDeserialized; + expect(savedTemplate.name).toBe('new_template'); + expect(savedTemplate.indexPatterns).toEqual(['index-*']); + expect(savedTemplate.indexMode).toBe('standard'); + expect(savedTemplate.allowAutoCreate).toBe('NO_OVERWRITE'); + expect(savedTemplate.composedOf).toEqual(['component_1']); + expect(savedTemplate._kbnMeta).toEqual({ + type: 'default', + hasDatastream: false, + isLegacy: false, + }); + expect(savedTemplate.template).toEqual({ + settings, + mappings, + aliases, + }); + }); + }); + + describe('WHEN editing an existing template', () => { + const existingTemplate: TemplateDeserialized = { + name: 'existing_template', + indexPatterns: ['logs-*'], + priority: 5, + version: 2, + allowAutoCreate: 'TRUE', + indexMode: 'standard', + dataStream: { + hidden: true, + anyUnknownKey: 'should_be_kept', + }, + template: { + settings: { index: { number_of_shards: 1 } }, + mappings: { properties: { old_field: { type: 'keyword' } } }, + aliases: { existing_alias: {} }, + }, + composedOf: ['existing_component'], + ignoreMissingComponentTemplates: ['missing_component'], + _kbnMeta: { + type: 'default', + hasDatastream: true, + isLegacy: false, + }, + }; + + beforeEach(() => { + mockIsEditing = true; + mockLogisticsData = { + name: 'existing_template', + indexPatterns: ['logs-*', 'metrics-*'], + priority: 10, + version: 3, + allowAutoCreate: 'TRUE', + indexMode: 'standard', + dataStream: { + hidden: true, + anyUnknownKey: 'should_be_kept', + }, + }; + mockComponentsData = ['existing_component', 'new_component']; + mockSettingsData = { index: { number_of_shards: 3 } }; + mockMappingsData = { + properties: { + old_field: { type: 'keyword' }, + new_field: { type: 'text' }, + }, + }; + mockAliasesData = { updated_alias: { is_write_index: true } }; + }); + + it('SHOULD preserve _kbnMeta and ignoreMissingComponentTemplates from initial template', async () => { + const onSave = jest.fn(); + renderTemplateForm({ + onSave, + isEditing: true, + defaultValue: existingTemplate, + title: `Edit template '${existingTemplate.name}'`, + }); + + expect(screen.getByTestId('mockStepLogistics')).toBeInTheDocument(); + clickNextButton(); + await screen.findByTestId('mockStepComponents'); + clickNextButton(); + await screen.findByTestId('mockStepSettings'); + clickNextButton(); + await screen.findByTestId('mockStepMappings'); + clickNextButton(); + await screen.findByTestId('mockStepAliases'); + clickNextButton(); + await screen.findByTestId('mockStepReview'); + clickNextButton(); + + await waitFor(() => { + expect(onSave).toHaveBeenCalledTimes(1); + }); + + const savedTemplate = onSave.mock.calls[0][0] as TemplateDeserialized; + + // _kbnMeta comes from initialTemplate, not wizard data + expect(savedTemplate._kbnMeta).toEqual(existingTemplate._kbnMeta); + // ignoreMissingComponentTemplates comes from initialTemplate + expect(savedTemplate.ignoreMissingComponentTemplates).toEqual( + existingTemplate.ignoreMissingComponentTemplates + ); + // Updated values from wizard + expect(savedTemplate.indexPatterns).toEqual(['logs-*', 'metrics-*']); + expect(savedTemplate.priority).toBe(10); + expect(savedTemplate.version).toBe(3); + expect(savedTemplate.composedOf).toEqual(['existing_component', 'new_component']); + expect(savedTemplate.template).toEqual({ + settings: { index: { number_of_shards: 3 } }, + mappings: { + properties: { + old_field: { type: 'keyword' }, + new_field: { type: 'text' }, + }, + }, + aliases: { updated_alias: { is_write_index: true } }, + }); + // dataStream is preserved from logistics + expect(savedTemplate.dataStream).toEqual({ + hidden: true, + anyUnknownKey: 'should_be_kept', + }); + }); + }); + + describe('WHEN cloning a template', () => { + const originalTemplate: TemplateDeserialized = { + name: 'original-copy', + indexPatterns: ['index-1', 'index-2'], + priority: 3, + version: 1, + allowAutoCreate: 'NO_OVERWRITE', + indexMode: 'standard', + dataStream: {}, + template: { + settings: { index: { number_of_shards: 1 } }, + mappings: { properties: { field_1: { type: 'keyword' } } }, + aliases: { my_alias: { is_write_index: true } }, + }, + composedOf: ['component_1'], + ignoreMissingComponentTemplates: [], + _kbnMeta: { + type: 'default', + hasDatastream: false, + isLegacy: false, + }, + }; + + beforeEach(() => { + // Clone flow: all wizard data matches the original + const { _kbnMeta: _, template: __, ...logistics } = originalTemplate; + mockLogisticsData = logistics; + mockComponentsData = originalTemplate.composedOf; + mockSettingsData = originalTemplate.template?.settings; + mockMappingsData = originalTemplate.template?.mappings; + mockAliasesData = originalTemplate.template?.aliases; + }); + + it('SHOULD produce a payload matching the original template shape', async () => { + const onSave = jest.fn(); + renderTemplateForm({ + onSave, + defaultValue: originalTemplate, + title: `Clone template '${originalTemplate.name}'`, + }); + + // Navigate through all steps + clickNextButton(); + await screen.findByTestId('mockStepComponents'); + clickNextButton(); + await screen.findByTestId('mockStepSettings'); + clickNextButton(); + await screen.findByTestId('mockStepMappings'); + clickNextButton(); + await screen.findByTestId('mockStepAliases'); + clickNextButton(); + await screen.findByTestId('mockStepReview'); + clickNextButton(); + + await waitFor(() => { + expect(onSave).toHaveBeenCalledTimes(1); + }); + + const savedTemplate = onSave.mock.calls[0][0] as TemplateDeserialized; + expect(savedTemplate.name).toBe('original-copy'); + expect(savedTemplate.composedOf).toEqual(['component_1']); + expect(savedTemplate._kbnMeta).toEqual(originalTemplate._kbnMeta); + expect(savedTemplate.template).toEqual(originalTemplate.template); + }); + }); + + describe('WHEN template sections are empty', () => { + beforeEach(() => { + mockLogisticsData = { + name: 'minimal_template', + indexPatterns: ['minimal-*'], + dataStream: {}, + indexMode: 'standard', + allowAutoCreate: 'NO_OVERWRITE', + }; + mockComponentsData = []; + mockSettingsData = undefined; + mockMappingsData = undefined; + mockAliasesData = undefined; + }); + + it('SHOULD omit empty template sections from the payload', async () => { + const onSave = jest.fn(); + renderTemplateForm({ onSave }); + + clickNextButton(); + await screen.findByTestId('mockStepComponents'); + clickNextButton(); + await screen.findByTestId('mockStepSettings'); + clickNextButton(); + await screen.findByTestId('mockStepMappings'); + clickNextButton(); + await screen.findByTestId('mockStepAliases'); + clickNextButton(); + await screen.findByTestId('mockStepReview'); + clickNextButton(); + + await waitFor(() => { + expect(onSave).toHaveBeenCalledTimes(1); + }); + + const savedTemplate = onSave.mock.calls[0][0] as TemplateDeserialized; + // When settings, mappings, and aliases are all undefined the template object + // should only contain lifecycle (which is also undefined here but still present + // as a key because cleanupTemplateObject only strips the three known keys). + expect(savedTemplate.template?.settings).toBeUndefined(); + expect(savedTemplate.template?.mappings).toBeUndefined(); + expect(savedTemplate.template?.aliases).toBeUndefined(); + }); + }); + + describe('WHEN lifecycle is configured', () => { + beforeEach(() => { + mockLogisticsData = { + name: 'template_with_lifecycle', + indexPatterns: ['data-*'], + dataStream: {}, + indexMode: 'standard', + allowAutoCreate: 'NO_OVERWRITE', + lifecycle: { enabled: true, value: 7, unit: 'd' }, + }; + mockComponentsData = []; + mockSettingsData = undefined; + mockMappingsData = undefined; + mockAliasesData = undefined; + }); + + it('SHOULD serialize lifecycle into template.lifecycle and remove top-level lifecycle', async () => { + const onSave = jest.fn(); + renderTemplateForm({ onSave }); + + clickNextButton(); + await screen.findByTestId('mockStepComponents'); + clickNextButton(); + await screen.findByTestId('mockStepSettings'); + clickNextButton(); + await screen.findByTestId('mockStepMappings'); + clickNextButton(); + await screen.findByTestId('mockStepAliases'); + clickNextButton(); + await screen.findByTestId('mockStepReview'); + clickNextButton(); + + await waitFor(() => { + expect(onSave).toHaveBeenCalledTimes(1); + }); + + const savedTemplate = onSave.mock.calls[0][0] as TemplateDeserialized; + // lifecycle should be serialized into template.lifecycle + expect(savedTemplate.template?.lifecycle).toEqual({ + enabled: true, + data_retention: '7d', + }); + // Top-level lifecycle should be removed + expect(Object.prototype.hasOwnProperty.call(savedTemplate, 'lifecycle')).toBe(false); + }); + }); +}); diff --git a/x-pack/platform/plugins/shared/index_management/public/application/components/template_form/utils/build_template_from_wizard_data.test.ts b/x-pack/platform/plugins/shared/index_management/public/application/components/template_form/utils/build_template_from_wizard_data.test.ts index 3e79ce042ff07..4f04644a5c841 100644 --- a/x-pack/platform/plugins/shared/index_management/public/application/components/template_form/utils/build_template_from_wizard_data.test.ts +++ b/x-pack/platform/plugins/shared/index_management/public/application/components/template_form/utils/build_template_from_wizard_data.test.ts @@ -40,7 +40,11 @@ describe('buildTemplateFromWizardData', () => { lifecycle: { enabled: true, value: 1, unit: 'd' }, }, settings: undefined, - mappings: undefined, + mappings: { + properties: { + field_1: { type: 'text' }, + }, + }, aliases: undefined, components: [], }, @@ -50,6 +54,11 @@ describe('buildTemplateFromWizardData', () => { hidden: true, anyUnknownKey: 'should_be_kept', }); + expect(result.template?.mappings).toEqual({ + properties: { + field_1: { type: 'text' }, + }, + }); expect(result.version).toBe(1); expect(Object.prototype.hasOwnProperty.call(result, 'lifecycle')).toBe(false); expect(result.template?.lifecycle).toEqual({ @@ -287,4 +296,126 @@ describe('buildTemplateFromWizardData', () => { expect(result.deprecated).toBe(true); expect(result.ignoreMissingComponentTemplates).toEqual(['initial_missing_component']); }); + + test('builds clone payload shape when wizard keeps template sections', () => { + const initialTemplate: TemplateDeserialized = { + name: 'my_template-copy', + indexPatterns: ['index-1'], + priority: 3, + version: 1, + allowAutoCreate: 'NO_OVERWRITE', + indexMode: 'standard', + dataStream: {}, + template: { + settings: { index: { number_of_shards: 1 } }, + mappings: { properties: { field_1: { type: 'keyword' } } }, + aliases: { my_alias: { is_write_index: true } }, + }, + ignoreMissingComponentTemplates: [], + composedOf: ['component_1'], + _kbnMeta: { + type: 'default', + hasDatastream: false, + isLegacy: false, + }, + }; + + const { _kbnMeta: _ignoredKbnMeta, template: _ignoredTemplate, ...logistics } = initialTemplate; + + const result = buildTemplateFromWizardData({ + initialTemplate, + wizardData: { + logistics, + settings: initialTemplate.template?.settings, + mappings: initialTemplate.template?.mappings, + aliases: initialTemplate.template?.aliases, + components: initialTemplate.composedOf, + }, + }); + + expect(result).toEqual({ + ...logistics, + _kbnMeta: initialTemplate._kbnMeta, + deprecated: initialTemplate.deprecated, + composedOf: initialTemplate.composedOf, + template: initialTemplate.template, + ignoreMissingComponentTemplates: [], + }); + }); + + test('includes composedOf + ignoreMissingComponentTemplates for missing component templates', () => { + const missing = 'component_template@custom'; + const initialTemplate: TemplateDeserialized = { + name: 'template_with_missing_component', + indexPatterns: ['indexPattern1'], + dataStream: {}, + indexMode: 'standard', + template: {}, + allowAutoCreate: 'NO_OVERWRITE', + ignoreMissingComponentTemplates: [missing], + composedOf: [], + _kbnMeta: { + type: 'default', + hasDatastream: false, + isLegacy: false, + }, + }; + + const { _kbnMeta: _ignoredKbnMeta, template: _ignoredTemplate, ...logistics } = initialTemplate; + + const result = buildTemplateFromWizardData({ + initialTemplate, + wizardData: { + logistics, + settings: undefined, + mappings: undefined, + aliases: undefined, + components: [missing], + }, + }); + + expect(result.composedOf).toEqual([missing]); + expect(result.ignoreMissingComponentTemplates).toEqual([missing]); + }); + + test('preserves legacy mappings types in mappings payload', () => { + const initialTemplate: TemplateDeserialized = { + name: 'legacy_template', + indexPatterns: ['indexPattern1'], + dataStream: {}, + indexMode: 'standard', + template: {}, + allowAutoCreate: 'NO_OVERWRITE', + ignoreMissingComponentTemplates: [], + composedOf: [], + _kbnMeta: { + type: 'default', + hasDatastream: false, + isLegacy: true, + }, + }; + + const { _kbnMeta: _ignoredKbnMeta, template: _ignoredTemplate, ...logistics } = initialTemplate; + + const legacyMappings = { + my_mapping_type: { + properties: { + field_1: { type: 'keyword' }, + }, + }, + }; + + const result = buildTemplateFromWizardData({ + initialTemplate, + wizardData: { + logistics, + settings: undefined, + mappings: legacyMappings, + aliases: undefined, + components: [], + }, + }); + + expect(result.template?.mappings).toEqual(legacyMappings); + }); }); diff --git a/x-pack/platform/plugins/shared/index_management/public/application/sections/template_clone/template_clone.test.tsx b/x-pack/platform/plugins/shared/index_management/public/application/sections/template_clone/template_clone.test.tsx new file mode 100644 index 0000000000000..e8e3fcb593ddc --- /dev/null +++ b/x-pack/platform/plugins/shared/index_management/public/application/sections/template_clone/template_clone.test.tsx @@ -0,0 +1,214 @@ +/* + * 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 React from 'react'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { I18nProvider } from '@kbn/i18n-react'; +import { createMemoryHistory } from 'history'; +import { matchPath } from 'react-router-dom'; + +import type { TemplateDeserialized } from '../../../../common'; +import { breadcrumbService } from '../../services/breadcrumbs'; +import { getTemplateDetailsLink } from '../../services/routing'; +import { saveTemplate, useLoadIndexTemplate } from '../../services/api'; +import { TemplateClone } from './template_clone'; +import type { UseRequestResponse, Error as EsUiSharedError } from '../../../shared_imports'; + +jest.mock('../../services/api', () => ({ + ...jest.requireActual('../../services/api'), + saveTemplate: jest.fn(), + useLoadIndexTemplate: jest.fn(), +})); + +jest.mock('../../app_context', () => ({ + ...jest.requireActual('../../app_context'), + useAppContext: jest.fn(() => ({ + config: { enableLegacyTemplates: true }, + })), +})); + +interface TemplateFormMockProps { + defaultValue: TemplateDeserialized; + onSave: (t: TemplateDeserialized) => void; + title: React.ReactNode; +} + +const mockTemplateFormPropsSpy = jest.fn(); +jest.mock('../../components', () => ({ + __esModule: true, + TemplateForm: (props: TemplateFormMockProps) => { + mockTemplateFormPropsSpy(props); + const { defaultValue, onSave, title } = props; + return ( +
+
{title}
+
{defaultValue?.name}
+
+ ); + }, +})); + +jest.mock('../../../shared_imports', () => ({ + PageLoading: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), + PageError: ({ 'data-test-subj': dataTestSubj }: { 'data-test-subj'?: string }) => ( +
+ ), + attemptToURIDecode: (value: string) => value, +})); + +const renderWithProviders = (ui: React.ReactElement) => render({ui}); + +const createRouterProps = ({ name, search = '' }: { name: string; search?: string }) => { + const history = createMemoryHistory({ + initialEntries: [`/clone_template/${encodeURIComponent(name)}${search}`], + }); + const match = matchPath<{ name: string }>(history.location.pathname, { + path: '/clone_template/:name', + exact: true, + strict: false, + }); + + if (!match) { + throw new Error('Expected route to match /clone_template/:name'); + } + + return { history, location: history.location, match }; +}; + +const makeTemplate = (overrides: Partial = {}): TemplateDeserialized => ({ + name: 'my_template', + indexPatterns: ['indexPattern1'], + version: 1, + allowAutoCreate: 'NO_OVERWRITE', + indexMode: 'standard', + dataStream: {}, + template: {}, + ignoreMissingComponentTemplates: [], + composedOf: [], + _kbnMeta: { + type: 'default', + hasDatastream: false, + isLegacy: false, + }, + ...overrides, +}); + +const createRequestError = (message: string): EsUiSharedError => ({ error: message, message }); + +const getUseRequestMock = ({ + isInitialRequest = false, + isLoading, + error, + data, +}: { + isInitialRequest?: boolean; + isLoading: boolean; + error: EsUiSharedError | null; + data: T | null; +}): UseRequestResponse => ({ + isInitialRequest, + isLoading, + error, + data, + resendRequest: jest.fn(), +}); + +describe('TemplateClone', () => { + beforeEach(() => { + breadcrumbService.setup(jest.fn()); + jest.restoreAllMocks(); + jest.clearAllMocks(); + mockTemplateFormPropsSpy.mockClear(); + const okResponse: Awaited> = { data: null, error: null }; + jest.mocked(saveTemplate).mockResolvedValue(okResponse); + }); + + describe('WHEN the template is loading', () => { + it('SHOULD render the loading state', () => { + jest + .mocked(useLoadIndexTemplate) + .mockReturnValue( + getUseRequestMock({ isLoading: true, error: null, data: null }) + ); + + const { history, location, match } = createRouterProps({ name: 'my_template' }); + renderWithProviders(); + + expect(screen.getByTestId('pageLoading')).toBeInTheDocument(); + }); + }); + + describe('WHEN the template load fails', () => { + it('SHOULD render the error state', () => { + jest.mocked(useLoadIndexTemplate).mockReturnValue( + getUseRequestMock({ + isLoading: false, + error: createRequestError('boom'), + data: null, + }) + ); + + const { history, location, match } = createRouterProps({ name: 'my_template' }); + renderWithProviders(); + + expect(screen.getByTestId('sectionError')).toBeInTheDocument(); + }); + }); + + describe('WHEN the template is loaded successfully', () => { + it('SHOULD set the cloned default name and wire save with clone flag', async () => { + const template = makeTemplate({ + name: 'my_template', + indexPatterns: ['index-1', 'index-2'], + version: 7, + allowAutoCreate: 'TRUE', + indexMode: 'standard', + template: { settings: { index: { number_of_shards: 1 } } }, + }); + jest + .mocked(useLoadIndexTemplate) + .mockReturnValue(getUseRequestMock({ isLoading: false, error: null, data: template })); + + const { history, location, match } = createRouterProps({ name: template.name }); + const pushSpy = jest.spyOn(history, 'push'); + + renderWithProviders(); + + expect(await screen.findByTestId('mockTemplateFormDefaultName')).toHaveTextContent( + `${template.name}-copy` + ); + expect(screen.getByTestId('mockTemplateFormTitle')).toHaveTextContent( + `Clone template '${template.name}'` + ); + + fireEvent.click(screen.getByTestId('mockTemplateFormSave')); + + await waitFor(() => { + expect(saveTemplate).toHaveBeenCalledTimes(1); + }); + + const [savedTemplate, cloneFlag] = jest.mocked(saveTemplate).mock.calls[0]; + + expect(savedTemplate).toEqual({ + ...template, + name: `${template.name}-copy`, + }); + expect(cloneFlag).toBe(true); + + await waitFor(() => { + expect(pushSpy).toHaveBeenCalledWith(getTemplateDetailsLink(savedTemplate.name, false)); + }); + }); + }); +}); diff --git a/x-pack/platform/plugins/shared/index_management/public/application/sections/template_create/template_create.test.tsx b/x-pack/platform/plugins/shared/index_management/public/application/sections/template_create/template_create.test.tsx new file mode 100644 index 0000000000000..463274deea37a --- /dev/null +++ b/x-pack/platform/plugins/shared/index_management/public/application/sections/template_create/template_create.test.tsx @@ -0,0 +1,182 @@ +/* + * 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 React from 'react'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { I18nProvider } from '@kbn/i18n-react'; +import { createMemoryHistory } from 'history'; +import { matchPath } from 'react-router-dom'; + +import type { TemplateDeserialized } from '../../../../common'; +import { breadcrumbService } from '../../services/breadcrumbs'; +import { getTemplateDetailsLink } from '../../services/routing'; +import { saveTemplate } from '../../services/api'; +import { TemplateCreate } from './template_create'; + +jest.mock('../../services/api', () => ({ + ...jest.requireActual('../../services/api'), + saveTemplate: jest.fn(), +})); + +const mockUseAppContext = jest.fn(); +jest.mock('../../app_context', () => ({ + ...jest.requireActual('../../app_context'), + useAppContext: () => mockUseAppContext(), +})); + +const mockUseLocation = jest.fn(); +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useLocation: () => mockUseLocation(), +})); + +let mockTemplateToSave: TemplateDeserialized | null = null; +const mockTemplateFormPropsSpy = jest.fn(); + +interface TemplateFormMockProps { + defaultValue?: TemplateDeserialized; + onSave: (t: TemplateDeserialized) => void; + title: React.ReactNode; + isLegacy?: boolean; +} + +jest.mock('../../components', () => ({ + __esModule: true, + TemplateForm: (props: TemplateFormMockProps) => { + mockTemplateFormPropsSpy(props); + const { defaultValue, onSave, title } = props; + return ( +
+
{title}
+
{String(props.isLegacy)}
+
+ ); + }, +})); + +const renderWithProviders = (ui: React.ReactElement) => render({ui}); + +describe('TemplateCreate', () => { + beforeEach(() => { + breadcrumbService.setup(jest.fn()); + jest.restoreAllMocks(); + jest.clearAllMocks(); + mockTemplateFormPropsSpy.mockClear(); + mockUseLocation.mockReturnValue({ search: '' }); + mockTemplateToSave = null; + mockUseAppContext.mockReturnValue({ config: { enableLegacyTemplates: true } }); + const okResponse: Awaited> = { data: null, error: null }; + jest.mocked(saveTemplate).mockResolvedValue(okResponse); + }); + + describe('WHEN legacy query param is set and legacy templates are enabled', () => { + it('SHOULD pass isLegacy=true to TemplateForm', () => { + mockUseLocation.mockReturnValue({ search: '?legacy=true' }); + const history = createMemoryHistory({ initialEntries: ['/create_template'] }); + const match = matchPath(history.location.pathname, { + path: '/create_template', + exact: true, + strict: false, + }); + if (!match) { + throw new Error('Expected route to match /create_template'); + } + + renderWithProviders( + + ); + + expect(screen.getByTestId('mockTemplateFormIsLegacy')).toHaveTextContent('true'); + expect(screen.getByTestId('mockTemplateFormTitle')).toHaveTextContent( + 'Create legacy template' + ); + }); + }); + + describe('WHEN legacy query param is set but legacy templates are disabled', () => { + it('SHOULD force isLegacy=false', () => { + mockUseLocation.mockReturnValue({ search: '?legacy=true' }); + mockUseAppContext.mockReturnValue({ config: { enableLegacyTemplates: false } }); + const history = createMemoryHistory({ initialEntries: ['/create_template'] }); + const match = matchPath(history.location.pathname, { + path: '/create_template', + exact: true, + strict: false, + }); + if (!match) { + throw new Error('Expected route to match /create_template'); + } + + renderWithProviders( + + ); + + expect(screen.getByTestId('mockTemplateFormIsLegacy')).toHaveTextContent('false'); + expect(screen.getByTestId('mockTemplateFormTitle')).toHaveTextContent('Create template'); + }); + }); + + describe('WHEN the form is saved', () => { + it('SHOULD call saveTemplate and navigate to the template details', async () => { + const history = createMemoryHistory({ initialEntries: ['/create_template'] }); + const match = matchPath(history.location.pathname, { + path: '/create_template', + exact: true, + strict: false, + }); + if (!match) { + throw new Error('Expected route to match /create_template'); + } + const pushSpy = jest.spyOn(history, 'push'); + + const template: TemplateDeserialized = { + name: 'new_template', + indexPatterns: ['index-*'], + dataStream: {}, + indexMode: 'standard', + template: {}, + allowAutoCreate: 'NO_OVERWRITE', + ignoreMissingComponentTemplates: [], + composedOf: [], + _kbnMeta: { type: 'default', hasDatastream: false, isLegacy: false }, + }; + + mockTemplateToSave = template; + + renderWithProviders( + + ); + + fireEvent.click(screen.getByTestId('mockTemplateFormSave')); + + await waitFor(() => { + expect(saveTemplate).toHaveBeenCalledWith(template); + expect(pushSpy).toHaveBeenCalledWith(getTemplateDetailsLink(template.name, false)); + }); + }); + }); +}); diff --git a/x-pack/platform/plugins/shared/index_management/public/application/sections/template_edit/template_edit.test.tsx b/x-pack/platform/plugins/shared/index_management/public/application/sections/template_edit/template_edit.test.tsx index b12fd01ded533..138382b0d7667 100644 --- a/x-pack/platform/plugins/shared/index_management/public/application/sections/template_edit/template_edit.test.tsx +++ b/x-pack/platform/plugins/shared/index_management/public/application/sections/template_edit/template_edit.test.tsx @@ -13,15 +13,19 @@ import { matchPath } from 'react-router-dom'; import type { TemplateDeserialized } from '../../../../common'; import { API_BASE_PATH } from '../../../../common/constants'; -import type { AppDependencies } from '../../app_context'; -import { AppContextProvider } from '../../app_context'; import { breadcrumbService } from '../../services/breadcrumbs'; import { setUiMetricService } from '../../services/api'; import { getTemplateDetailsLink } from '../../services/routing'; import { sendRequest, useRequest } from '../../services/use_request'; import { TemplateEdit } from './template_edit'; -import type { UiMetricService } from '../../services/ui_metric'; -import type { UseRequestResponse } from '../../../shared_imports'; +import { UiMetricService } from '../../services/ui_metric'; +import type { UseRequestResponse, Error as EsUiSharedError } from '../../../shared_imports'; + +const mockUseAppContext = jest.fn(); +jest.mock('../../app_context', () => ({ + ...jest.requireActual('../../app_context'), + useAppContext: () => mockUseAppContext(), +})); jest.mock('../../services/use_request', () => ({ __esModule: true, @@ -44,30 +48,28 @@ jest.mock('../../components', () => ({ TemplateForm: ({ defaultValue, onSave, + isEditing, + title, }: { defaultValue: TemplateDeserialized; onSave: (t: TemplateDeserialized) => void; + isEditing?: boolean; + title?: React.ReactNode; }) => ( -
), })); const renderWithProviders = (ui: React.ReactElement) => { - const deps = { - config: { - enableLegacyTemplates: true, - }, - } as unknown as AppDependencies; - - return render( - - {ui} - - ); + return render({ui}); }; const createRouterProps = ({ name, search = '' }: { name: string; search?: string }) => { @@ -111,6 +113,8 @@ const makeTemplate = (overrides: Partial = {}): TemplateDe ...overrides, }); +const createRequestError = (message: string): EsUiSharedError => ({ error: message, message }); + const getUseRequestMock = ({ isInitialRequest = false, isLoading, @@ -119,9 +123,9 @@ const getUseRequestMock = ({ }: { isInitialRequest?: boolean; isLoading: boolean; - error: Error | null; + error: EsUiSharedError | null; data: T | null; -}): UseRequestResponse => ({ +}): UseRequestResponse => ({ isInitialRequest, isLoading, error, @@ -132,16 +136,20 @@ const getUseRequestMock = ({ describe('TemplateEdit', () => { beforeEach(() => { breadcrumbService.setup(jest.fn()); - setUiMetricService({ trackMetric: jest.fn() } as unknown as UiMetricService); + setUiMetricService(new UiMetricService('index_management')); jest.mocked(sendRequest).mockResolvedValue({ data: null, error: null }); + mockUseAppContext.mockReturnValue({ config: { enableLegacyTemplates: true } }); }); test('renders loading state', () => { - jest - .mocked(useRequest) - .mockReturnValue( - getUseRequestMock({ isInitialRequest: true, isLoading: true, error: null, data: null }) - ); + jest.mocked(useRequest).mockReturnValue( + getUseRequestMock({ + isInitialRequest: true, + isLoading: true, + error: null, + data: null, + }) + ); const { history, location, match } = createRouterProps({ name: 'my_template' }); renderWithProviders(); @@ -150,11 +158,13 @@ describe('TemplateEdit', () => { }); test('renders error state when load fails', () => { - jest - .mocked(useRequest) - .mockReturnValue( - getUseRequestMock({ isLoading: false, error: new Error('boom'), data: null }) - ); + jest.mocked(useRequest).mockReturnValue( + getUseRequestMock({ + isLoading: false, + error: createRequestError('boom'), + data: null, + }) + ); const { history, location, match } = createRouterProps({ name: 'my_template' }); renderWithProviders(); @@ -219,6 +229,11 @@ describe('TemplateEdit', () => { renderWithProviders(); + expect(screen.getByTestId('mockTemplateFormTitle')).toHaveTextContent( + `Edit template '${template.name}'` + ); + expect(screen.getByTestId('mockIsEditing')).toHaveTextContent('true'); + fireEvent.click(screen.getByTestId('mockTemplateFormSave')); await waitFor(() => @@ -229,6 +244,8 @@ describe('TemplateEdit', () => { }) ); - expect(pushSpy).toHaveBeenCalledWith(getTemplateDetailsLink(template.name, false)); + await waitFor(() => { + expect(pushSpy).toHaveBeenCalledWith(getTemplateDetailsLink(template.name, false)); + }); }); });