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
;
-}
-
-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 (
+
+
+
+ );
+};
+
+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 ;
+}
+
+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 (
+
+
+
+ );
+};
+
+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}
+
onSave(defaultValue)}
+ />
+
+ );
+ },
+}));
+
+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)}
+
+ onSave(
+ mockTemplateToSave ??
+ defaultValue ?? {
+ name: 'new_template',
+ indexPatterns: ['index-*'],
+ dataStream: {},
+ indexMode: 'standard',
+ template: {},
+ allowAutoCreate: 'NO_OVERWRITE',
+ ignoreMissingComponentTemplates: [],
+ composedOf: [],
+ _kbnMeta: { type: 'default', hasDatastream: false, isLegacy: false },
+ }
+ )
+ }
+ />
+
+ );
+ },
+}));
+
+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;
}) => (
- onSave(defaultValue)}
- />
+
+
{title}
+
{String(Boolean(isEditing))}
+
onSave(defaultValue)}
+ />
+
),
}));
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));
+ });
});
});