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/__jest__/client_integration/index_details_page/select_inference_id.test.tsx
deleted file mode 100644
index 637b4defc5eaa..0000000000000
--- a/x-pack/platform/plugins/shared/index_management/__jest__/client_integration/index_details_page/select_inference_id.test.tsx
+++ /dev/null
@@ -1,598 +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 } from '@testing-library/react';
-import { act } from 'react-dom/test-utils';
-import type { InferenceTaskType } from '@elastic/elasticsearch/lib/api/types';
-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';
-
-const mockDispatch = jest.fn();
-const mockNavigateToUrl = jest.fn();
-const INFERENCE_LOCATOR = 'SEARCH_INFERENCE_ENDPOINTS';
-
-const createMockLocator = () => ({
- useUrl: jest.fn().mockReturnValue('https://redirect.me/to/inference_endpoints'),
-});
-
-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) => {
- if (id === INFERENCE_LOCATOR) {
- return createMockLocator();
- }
- throw new Error(`Unknown locator id: ${id}`);
- }),
- },
- },
- },
- },
- })),
-}));
-
-jest.mock(
- '../../../public/application/components/component_templates/component_templates_context',
- () => ({
- ...jest.requireActual(
- '../../../public/application/components/component_templates/component_templates_context'
- ),
- 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,
-}));
-
-const mockResendRequest = jest.fn();
-
-const MockInferenceFlyoutWrapper = ({
- onFlyoutClose,
- onSubmitSuccess,
- allowedTaskTypes,
-}: {
- onFlyoutClose: () => void;
- onSubmitSuccess: (id: string) => void;
- http?: unknown;
- toasts?: unknown;
- isEdit?: boolean;
- enforceAdaptiveAllocations?: boolean;
- allowedTaskTypes?: InferenceTaskType[];
-}) => (
-
-
-
- {allowedTaskTypes && (
-
{allowedTaskTypes.join(',')}
- )}
-
-);
-
-jest.mock('@kbn/inference-endpoint-ui-common', () => ({
- __esModule: true,
- default: MockInferenceFlyoutWrapper,
-}));
-
-jest.mock('../../../public/application/services/api', () => ({
- ...jest.requireActual('../../../public/application/services/api'),
- useLoadInferenceEndpoints: jest.fn(),
-}));
-
-const DEFAULT_ENDPOINTS: InferenceAPIConfigResponse[] = [
- { inference_id: '.preconfigured-elser', task_type: 'sparse_embedding' },
- { inference_id: '.preconfigured-e5', task_type: 'text_embedding' },
- { inference_id: 'endpoint-1', task_type: 'text_embedding' },
- { inference_id: 'endpoint-2', task_type: 'sparse_embedding' },
-] as InferenceAPIConfigResponse[];
-
-function TestFormWrapper({
- children,
- initialValue = '.preconfigured-elser',
-}: {
- children: React.ReactElement;
- initialValue?: string;
-}) {
- const { form } = useForm();
-
- React.useEffect(() => {
- if (initialValue) {
- form.setFieldValue('inference_id', initialValue);
- }
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, []);
-
- return ;
-}
-
-function setupMocks(
- data: InferenceAPIConfigResponse[] | undefined = DEFAULT_ENDPOINTS,
- isLoading: boolean = false,
- error: Error | null = null
-) {
- const { useLoadInferenceEndpoints } = jest.requireMock(
- '../../../public/application/services/api'
- );
-
- mockResendRequest.mockClear();
- useLoadInferenceEndpoints.mockReturnValue({
- data,
- isLoading,
- error,
- resendRequest: mockResendRequest,
- });
-}
-
-const defaultProps: SelectInferenceIdProps = {
- 'data-test-subj': 'data-inference-endpoint-list',
-};
-
-const flushPendingTimers = async () => {
- await act(async () => {
- await jest.runOnlyPendingTimersAsync();
- });
-};
-
-const actClick = async (element: Element) => {
- // EUI's popover positioning happens async (via MutationObserver/requestAnimationFrame).
- // When a test ends, RTL unmounts the component but those async callbacks are
- // already queued. They still fire after unmount, trying to setState on a
- // now-dead component, which triggers React's "update not wrapped in
- // act(...)" warning in the next test's setup. Running a test in isolation
- // usually doesn't show it because timing works out differently. This helper
- // clicks, then flushes pending timers so the popover's async work completes
- // before the test ends.
- fireEvent.click(element);
- await flushPendingTimers();
-};
-
-let consoleErrorSpy: jest.SpyInstance;
-let originalConsoleError: typeof console.error;
-
-beforeAll(() => {
- /* eslint-disable no-console */
- originalConsoleError = console.error;
- consoleErrorSpy = jest
- .spyOn(console, 'error')
- .mockImplementation((message?: unknown, ...rest: unknown[]) => {
- if (
- typeof message === 'string' &&
- message.includes('The truncation ellipsis is larger than the available width')
- ) {
- return;
- }
- originalConsoleError(message, ...rest);
- });
- /* eslint-enable no-console */
-});
-
-afterAll(() => {
- consoleErrorSpy.mockRestore();
-});
-
-beforeEach(() => {
- jest.useFakeTimers();
- jest.clearAllMocks();
- const { useLoadInferenceEndpoints } = jest.requireMock(
- '../../../public/application/services/api'
- );
- useLoadInferenceEndpoints.mockReset();
-});
-
-afterEach(async () => {
- await flushPendingTimers();
- jest.useRealTimers();
-});
-
-describe('SelectInferenceId', () => {
- describe('WHEN component is rendered', () => {
- beforeEach(() => {
- setupMocks();
- });
-
- it('SHOULD display the component with button', async () => {
- render(
-
-
-
- );
-
- expect(await screen.findByTestId('selectInferenceId')).toBeInTheDocument();
- expect(await screen.findByTestId('inferenceIdButton')).toBeInTheDocument();
- });
-
- it('SHOULD display selected endpoint in button', async () => {
- render(
-
-
-
- );
-
- const button = await screen.findByTestId('inferenceIdButton');
- expect(button).toHaveTextContent('.preconfigured-elser');
- });
-
- it('SHOULD prioritize ELSER endpoint as default selection', async () => {
- render(
-
-
-
- );
-
- const button = await screen.findByTestId('inferenceIdButton');
- expect(button).toHaveTextContent('.preconfigured-elser');
- expect(button).not.toHaveTextContent('endpoint-1');
- expect(button).not.toHaveTextContent('endpoint-2');
- });
- });
-
- describe('WHEN popover button is clicked', () => {
- beforeEach(() => {
- setupMocks();
- });
-
- it('SHOULD open popover with management buttons', async () => {
- render(
-
-
-
- );
-
- await actClick(await screen.findByTestId('inferenceIdButton'));
-
- expect(await screen.findByTestId('createInferenceEndpointButton')).toBeInTheDocument();
- expect(await screen.findByTestId('manageInferenceEndpointButton')).toBeInTheDocument();
- });
-
- describe('AND button is clicked again', () => {
- it('SHOULD close the popover', async () => {
- render(
-
-
-
- );
-
- const toggle = await screen.findByTestId('inferenceIdButton');
-
- await actClick(toggle);
- expect(await screen.findByTestId('createInferenceEndpointButton')).toBeInTheDocument();
-
- await actClick(toggle);
- expect(screen.queryByTestId('createInferenceEndpointButton')).not.toBeInTheDocument();
- });
- });
-
- describe('AND "Add inference endpoint" button is clicked', () => {
- it('SHOULD close popover', async () => {
- render(
-
-
-
- );
-
- await actClick(await screen.findByTestId('inferenceIdButton'));
- expect(await screen.findByTestId('createInferenceEndpointButton')).toBeInTheDocument();
-
- await actClick(await screen.findByTestId('createInferenceEndpointButton'));
- expect(screen.queryByTestId('createInferenceEndpointButton')).not.toBeInTheDocument();
- });
- });
- });
-
- describe('WHEN endpoint is created optimistically', () => {
- beforeEach(() => {
- setupMocks();
- });
-
- it('SHOULD display newly created endpoint even if not in loaded list', async () => {
- render(
-
-
-
- );
-
- await actClick(await screen.findByTestId('inferenceIdButton'));
-
- const newEndpoint = await screen.findByTestId('custom-inference_newly-created-endpoint');
- expect(newEndpoint).toBeInTheDocument();
- expect(newEndpoint).toHaveAttribute('aria-checked', 'true');
- });
- });
-
- describe('WHEN flyout is opened', () => {
- beforeEach(() => {
- setupMocks();
- });
-
- it('SHOULD show flyout when "Add inference endpoint" is clicked', async () => {
- render(
-
-
-
- );
-
- await actClick(await screen.findByTestId('inferenceIdButton'));
- await actClick(await screen.findByTestId('createInferenceEndpointButton'));
-
- expect(await screen.findByTestId('inference-flyout-wrapper')).toBeInTheDocument();
- });
-
- it('SHOULD pass allowedTaskTypes to restrict endpoint creation to compatible types', async () => {
- render(
-
-
-
- );
-
- await actClick(await screen.findByTestId('inferenceIdButton'));
- await actClick(await screen.findByTestId('createInferenceEndpointButton'));
-
- const allowedTaskTypes = await screen.findByTestId('mock-allowed-task-types');
- expect(allowedTaskTypes).toHaveTextContent('text_embedding,sparse_embedding');
- });
-
- describe('AND flyout close is triggered', () => {
- it('SHOULD close the flyout', async () => {
- render(
-
-
-
- );
-
- await actClick(await screen.findByTestId('inferenceIdButton'));
- await actClick(await screen.findByTestId('createInferenceEndpointButton'));
- expect(await screen.findByTestId('inference-flyout-wrapper')).toBeInTheDocument();
-
- await actClick(await screen.findByTestId('mock-flyout-close'));
-
- expect(screen.queryByTestId('inference-flyout-wrapper')).not.toBeInTheDocument();
- });
- });
-
- describe('AND endpoint is successfully created', () => {
- it('SHOULD call resendRequest when submitted', async () => {
- render(
-
-
-
- );
-
- await actClick(await screen.findByTestId('inferenceIdButton'));
- await actClick(await screen.findByTestId('createInferenceEndpointButton'));
-
- expect(await screen.findByTestId('inference-flyout-wrapper')).toBeInTheDocument();
-
- await actClick(await screen.findByTestId('mock-flyout-submit'));
-
- expect(mockResendRequest).toHaveBeenCalledTimes(1);
- });
- });
- });
-
- describe('WHEN endpoint is selected from list', () => {
- beforeEach(() => {
- setupMocks();
- });
-
- it('SHOULD update form value with selected endpoint', async () => {
- render(
-
-
-
- );
-
- await actClick(await screen.findByTestId('inferenceIdButton'));
-
- const endpoint1 = await screen.findByTestId('custom-inference_endpoint-1');
- await actClick(endpoint1);
-
- const button = await screen.findByTestId('inferenceIdButton');
- expect(button).toHaveTextContent('endpoint-1');
- });
- });
-
- describe('WHEN user searches for endpoints', () => {
- beforeEach(() => {
- setupMocks();
- });
-
- it('SHOULD filter endpoints based on search input', async () => {
- render(
-
-
-
- );
-
- await actClick(await screen.findByTestId('inferenceIdButton'));
-
- const searchInput = await screen.findByRole('combobox', {
- name: /Existing endpoints/i,
- });
- fireEvent.change(searchInput, { target: { value: 'endpoint-1' } });
-
- expect(await screen.findByTestId('custom-inference_endpoint-1')).toBeInTheDocument();
- expect(screen.queryByTestId('custom-inference_endpoint-2')).not.toBeInTheDocument();
- });
- });
-
- describe('WHEN endpoints are loading', () => {
- it('SHOULD display loading spinner', async () => {
- setupMocks(undefined, true, null);
-
- render(
-
-
-
- );
-
- await actClick(await screen.findByTestId('inferenceIdButton'));
- await screen.findByTestId('createInferenceEndpointButton');
-
- const progressBars = screen.getAllByRole('progressbar');
- expect(progressBars.length).toBeGreaterThan(0);
- });
- });
-
- describe('WHEN endpoints list is empty', () => {
- it('SHOULD not set default value', async () => {
- setupMocks([], false, null);
-
- render(
-
-
-
- );
-
- await flushPendingTimers();
-
- const button = screen.getByTestId('inferenceIdButton');
- expect(button).toHaveTextContent('No inference endpoint selected');
- });
-
- it('SHOULD display "No inference endpoint selected" message', () => {
- setupMocks([], false, null);
-
- render(
-
-
-
- );
-
- const button = screen.getByTestId('inferenceIdButton');
- expect(button).toHaveTextContent('No inference endpoint selected');
- });
- });
-
- describe('WHEN only incompatible endpoints are available', () => {
- const incompatibleEndpoints: InferenceAPIConfigResponse[] = [
- { inference_id: 'incompatible-1', task_type: 'completion' },
- { inference_id: 'incompatible-2', task_type: 'rerank' },
- ] as InferenceAPIConfigResponse[];
-
- beforeEach(() => {
- setupMocks(incompatibleEndpoints);
- });
-
- it('SHOULD not display incompatible endpoints in list', async () => {
- render(
-
-
-
- );
-
- await actClick(await screen.findByTestId('inferenceIdButton'));
- await screen.findByTestId('createInferenceEndpointButton');
-
- expect(screen.queryByTestId('custom-inference_incompatible-1')).not.toBeInTheDocument();
- expect(screen.queryByTestId('custom-inference_incompatible-2')).not.toBeInTheDocument();
- });
- });
-
- describe('WHEN API returns error', () => {
- it('SHOULD handle error gracefully and still render UI', async () => {
- setupMocks([], false, new Error('Failed to load endpoints'));
-
- render(
-
-
-
- );
-
- expect(screen.getByTestId('selectInferenceId')).toBeInTheDocument();
-
- await actClick(await screen.findByTestId('inferenceIdButton'));
- expect(await screen.findByTestId('createInferenceEndpointButton')).toBeInTheDocument();
-
- expect(screen.queryByTestId('custom-inference_endpoint-1')).not.toBeInTheDocument();
- expect(screen.queryByTestId('custom-inference_endpoint-2')).not.toBeInTheDocument();
- });
- });
-
- describe('WHEN component mounts with empty value', () => {
- it('SHOULD automatically select default endpoint', async () => {
- setupMocks();
-
- render(
-
-
-
- );
-
- await flushPendingTimers();
-
- const button = screen.getByTestId('inferenceIdButton');
- expect(button).toHaveTextContent('.preconfigured-elser');
- });
-
- describe('AND .elser-2-elastic is available', () => {
- it('SHOULD prioritize .elser-2-elastic over other endpoints', async () => {
- setupMocks([
- { inference_id: '.elser-2-elastic', task_type: 'sparse_embedding' },
- { inference_id: '.preconfigured-elser', task_type: 'sparse_embedding' },
- { inference_id: 'endpoint-1', task_type: 'text_embedding' },
- ] as InferenceAPIConfigResponse[]);
-
- render(
-
-
-
- );
-
- await flushPendingTimers();
-
- const button = screen.getByTestId('inferenceIdButton');
- expect(button).toHaveTextContent('.elser-2-elastic');
- });
- });
- });
-});
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 b7dbd1d67b64c..0000000000000
--- a/x-pack/platform/plugins/shared/index_management/__jest__/client_integration/index_template_wizard/template_clone.test.tsx
+++ /dev/null
@@ -1,117 +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 { act } from 'react-dom/test-utils';
-
-import { API_BASE_PATH } from '../../../common/constants';
-import { getComposableTemplate } from '../../../test/fixtures';
-import { setupEnvironment } from '../helpers';
-
-import { TEMPLATE_NAME, INDEX_PATTERNS as DEFAULT_INDEX_PATTERNS } from './constants';
-import { setup } from './template_clone.helpers';
-import type { TemplateFormTestBed } from './template_form.helpers';
-
-jest.mock('@elastic/eui', () => {
- const original = jest.requireActual('@elastic/eui');
-
- return {
- ...original,
- // Mocking EuiComboBox, as it utilizes "react-virtualized" for rendering search suggestions,
- // which does not produce a valid component wrapper
- EuiComboBox: (props: any) => (
- {
- props.onChange([syntheticEvent['0']]);
- }}
- />
- ),
- };
-});
-
-const templateToClone = getComposableTemplate({
- name: TEMPLATE_NAME,
- indexPatterns: ['indexPattern1'],
- template: {},
- allowAutoCreate: 'TRUE',
-});
-
-describe('', () => {
- let testBed: TemplateFormTestBed;
- const { httpSetup, httpRequestsMockHelpers } = setupEnvironment();
-
- beforeAll(() => {
- jest.useFakeTimers({ legacyFakeTimers: true });
- httpRequestsMockHelpers.setLoadTelemetryResponse({});
- httpRequestsMockHelpers.setLoadComponentTemplatesResponse([]);
- httpRequestsMockHelpers.setLoadTemplateResponse(templateToClone.name, templateToClone);
- });
-
- afterAll(() => {
- jest.useRealTimers();
- });
-
- beforeEach(async () => {
- await act(async () => {
- testBed = await setup(httpSetup);
- });
- testBed.component.update();
- });
-
- test('should set the correct page title', () => {
- const { exists, find } = testBed;
-
- expect(exists('pageTitle')).toBe(true);
- expect(find('pageTitle').text()).toEqual(`Clone template '${templateToClone.name}'`);
- });
-
- describe('form payload', () => {
- beforeEach(async () => {
- const { actions } = testBed;
-
- // Logistics
- // Specify index patterns, but do not change name (keep default)
- await actions.completeStepOne({
- indexPatterns: DEFAULT_INDEX_PATTERNS,
- });
- // Component templates
- await actions.completeStepTwo();
- // Index settings
- await actions.completeStepThree();
- // Mappings
- await actions.completeStepFour();
- // Aliases
- await actions.completeStepFive();
- });
-
- it('should send the correct payload', async () => {
- const { actions } = testBed;
-
- await act(async () => {
- actions.clickNextButton();
- });
-
- const { template, indexMode, priority, version, _kbnMeta, allowAutoCreate } = templateToClone;
- expect(httpSetup.post).toHaveBeenLastCalledWith(
- `${API_BASE_PATH}/index_templates`,
- expect.objectContaining({
- body: JSON.stringify({
- name: `${templateToClone.name}-copy`,
- indexPatterns: DEFAULT_INDEX_PATTERNS,
- priority,
- version,
- allowAutoCreate,
- indexMode,
- _kbnMeta,
- template,
- }),
- })
- );
- });
- });
-});
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 d8e8532f1c66f..0000000000000
--- a/x-pack/platform/plugins/shared/index_management/__jest__/client_integration/index_template_wizard/template_edit.test.tsx
+++ /dev/null
@@ -1,468 +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 { act } from 'react-dom/test-utils';
-
-import * as fixtures from '../../../test/fixtures';
-import { API_BASE_PATH } from '../../../common/constants';
-import { setupEnvironment, kibanaVersion } from '../helpers';
-
-import {
- TEMPLATE_NAME,
- SETTINGS,
- ALIASES,
- MAPPINGS as DEFAULT_MAPPING,
- INDEX_PATTERNS,
-} from './constants';
-import { setup } from './template_edit.helpers';
-import type { TemplateFormTestBed } from './template_form.helpers';
-
-const UPDATED_INDEX_PATTERN = ['updatedIndexPattern'];
-const UPDATED_MAPPING_TEXT_FIELD_NAME = 'updated_text_datatype';
-const MAPPING = {
- ...DEFAULT_MAPPING,
- properties: {
- text_datatype: {
- type: 'text',
- },
- },
-};
-const NONEXISTENT_COMPONENT_TEMPLATE = {
- name: 'component_template@custom',
- hasMappings: false,
- hasAliases: false,
- hasSettings: false,
- usedBy: [],
-};
-
-const EXISTING_COMPONENT_TEMPLATE = {
- name: 'test_component_template',
- hasMappings: true,
- hasAliases: false,
- hasSettings: false,
- usedBy: [],
- isManaged: false,
-};
-
-jest.mock('@kbn/code-editor', () => {
- const original = jest.requireActual('@kbn/code-editor');
- return {
- ...original,
- // Mocking CodeEditor, which uses React Monaco under the hood
- CodeEditor: (props: any) => (
- ) => {
- props.onChange(e.currentTarget.getAttribute('data-currentvalue'));
- }}
- />
- ),
- };
-});
-
-jest.mock('@elastic/eui', () => {
- const origial = jest.requireActual('@elastic/eui');
-
- return {
- ...origial,
- // Mocking EuiComboBox, as it utilizes "react-virtualized" for rendering search suggestions,
- // which does not produce a valid component wrapper
- EuiComboBox: (props: any) => (
- {
- props.onChange([syntheticEvent['0']]);
- }}
- />
- ),
- };
-});
-
-describe('', () => {
- let testBed: TemplateFormTestBed;
-
- const { httpSetup, httpRequestsMockHelpers } = setupEnvironment();
-
- beforeAll(() => {
- jest.useFakeTimers({ legacyFakeTimers: true });
- httpRequestsMockHelpers.setLoadComponentTemplatesResponse([]);
- httpRequestsMockHelpers.setLoadComponentTemplatesResponse([EXISTING_COMPONENT_TEMPLATE]);
- });
-
- afterAll(() => {
- jest.useRealTimers();
- });
-
- // FLAKY: https://github.com/elastic/kibana/issues/253550
- describe.skip('without mappings', () => {
- const templateToEdit = fixtures.getTemplate({
- name: 'index_template_without_mappings',
- indexPatterns: ['indexPattern1'],
- dataStream: {
- hidden: true,
- anyUnknownKey: 'should_be_kept',
- },
- });
-
- beforeAll(() => {
- httpRequestsMockHelpers.setLoadTemplateResponse('my_template', templateToEdit);
- });
-
- beforeEach(async () => {
- await act(async () => {
- testBed = await setup(httpSetup);
- });
-
- testBed.component.update();
- });
-
- test('allows you to add mappings', async () => {
- const { actions, find } = testBed;
- // Logistics
- await actions.completeStepOne();
- // Component templates
- await actions.completeStepTwo();
- // Index settings
- await actions.completeStepThree();
- // Mappings
- await actions.mappings.addField('field_1', 'text');
-
- expect(find('fieldsListItem').length).toBe(1);
- });
-
- test('should keep data stream configuration', async () => {
- const { actions } = testBed;
- // Logistics
- await actions.completeStepOne({
- name: 'test',
- indexPatterns: ['myPattern*'],
- version: 1,
- lifecycle: {
- enabled: true,
- value: 1,
- unit: 'd',
- },
- });
- // Component templates
- await actions.completeStepTwo();
- // Index settings
- await actions.completeStepThree();
- // Mappings
- await actions.completeStepFour();
- // Aliases
- await actions.completeStepFive();
-
- await act(async () => {
- actions.clickNextButton();
- });
-
- expect(httpSetup.put).toHaveBeenLastCalledWith(
- `${API_BASE_PATH}/index_templates/test`,
- expect.objectContaining({
- body: JSON.stringify({
- name: 'test',
- indexPatterns: ['myPattern*'],
- version: 1,
- allowAutoCreate: 'NO_OVERWRITE',
- dataStream: {
- hidden: true,
- anyUnknownKey: 'should_be_kept',
- },
- indexMode: 'standard',
- _kbnMeta: {
- type: 'default',
- hasDatastream: true,
- isLegacy: false,
- },
- template: {
- lifecycle: {
- enabled: true,
- data_retention: '1d',
- },
- },
- }),
- })
- );
- });
- });
-
- describe('with mappings', () => {
- const templateToEdit = fixtures.getTemplate({
- name: TEMPLATE_NAME,
- indexPatterns: ['indexPattern1'],
- template: {
- mappings: MAPPING,
- },
- });
-
- beforeAll(() => {
- httpRequestsMockHelpers.setLoadTemplateResponse('my_template', templateToEdit);
- });
-
- beforeEach(async () => {
- await act(async () => {
- testBed = await setup(httpSetup);
- });
- testBed.component.update();
- });
-
- test('should set the correct page title', () => {
- const { exists, find } = testBed;
- const { name } = templateToEdit;
-
- expect(exists('pageTitle')).toBe(true);
- expect(find('pageTitle').text()).toEqual(`Edit template '${name}'`);
- });
-
- it('should set the nameField to readOnly', () => {
- const { find } = testBed;
-
- const nameInput = find('nameField.input');
- expect(nameInput.props().disabled).toEqual(true);
- });
-
- describe('form payload', () => {
- beforeEach(async () => {
- const { actions } = testBed;
-
- // Logistics
- await actions.completeStepOne({
- indexPatterns: UPDATED_INDEX_PATTERN,
- priority: 3,
- allowAutoCreate: 'TRUE',
- });
- // Component templates
- await actions.completeStepTwo();
- // Index settings
- await actions.completeStepThree(JSON.stringify(SETTINGS));
- });
-
- it('should send the correct payload with changed values', async () => {
- const { actions, component, exists, form } = testBed;
-
- // Make some changes to the mappings
- await act(async () => {
- actions.clickEditButtonAtField(0); // Select the first field to edit
- jest.advanceTimersByTime(0); // advance timers to allow the form to validate
- });
- component.update();
-
- // Verify that the edit field flyout is opened
- expect(exists('mappingsEditorFieldEdit')).toBe(true);
-
- // Change the field name
- await act(async () => {
- form.setInputValue('nameParameterInput', UPDATED_MAPPING_TEXT_FIELD_NAME);
- jest.advanceTimersByTime(0); // advance timers to allow the form to validate
- });
-
- // Save changes on the field
- await act(async () => {
- actions.clickEditFieldUpdateButton();
- });
- component.update();
-
- // Proceed to the next step
- await act(async () => {
- actions.clickNextButton();
- });
- component.update();
-
- // Aliases
- await actions.completeStepFive(JSON.stringify(ALIASES));
-
- // Submit the form
- await act(async () => {
- actions.clickNextButton();
- });
-
- 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,
- },
- }),
- })
- );
- });
- });
- });
-
- 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],
- });
-
- beforeAll(() => {
- httpRequestsMockHelpers.setLoadTemplateResponse('my_template', templateToEdit);
- });
-
- beforeEach(async () => {
- await act(async () => {
- testBed = await setup(httpSetup);
- });
- testBed.component.update();
- });
-
- it('the nonexistent component template should be selected in the Component templates selector', async () => {
- const { actions, exists } = testBed;
-
- // Complete step 1: Logistics
- await actions.completeStepOne();
- jest.advanceTimersByTime(0); // advance timers to allow the form to validate
-
- // Should be at the Component templates step
- expect(exists('stepComponents')).toBe(true);
-
- const {
- actions: {
- componentTemplates: { getComponentTemplatesSelected },
- },
- } = testBed;
-
- expect(exists('componentTemplatesSelection.emptyPrompt')).toBe(false);
- expect(getComponentTemplatesSelected()).toEqual([NONEXISTENT_COMPONENT_TEMPLATE.name]);
- });
-
- it('the composedOf and ignoreMissingComponentTemplates fields should be included in the final payload', async () => {
- const { component, actions, find } = testBed;
-
- // Complete step 1: Logistics
- await actions.completeStepOne();
- // Complete step 2: Component templates
- await actions.completeStepTwo();
- // Complete step 3: Index settings
- await actions.completeStepThree();
- // Complete step 4: Mappings
- await actions.completeStepFour();
- // Complete step 5: Aliases
- await actions.completeStepFive();
-
- expect(find('stepTitle').text()).toEqual(`Review details for '${TEMPLATE_NAME}'`);
-
- await act(async () => {
- actions.clickNextButton();
- });
- component.update();
-
- 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: {},
- },
- },
- });
-
- beforeAll(() => {
- httpRequestsMockHelpers.setLoadTemplateResponse('my_template', legacyTemplateToEdit);
- });
-
- beforeEach(async () => {
- await act(async () => {
- testBed = await setup(httpSetup);
- });
-
- testBed.component.update();
- });
-
- it('persists mappings type', async () => {
- const { actions } = testBed;
- // Logistics
- await actions.completeStepOne();
- // Note: "step 2" (component templates) doesn't exist for legacy templates
- // Index settings
- await actions.completeStepThree();
- // Mappings
- await actions.completeStepFour();
- // Aliases
- await actions.completeStepFive();
-
- // Submit the form
- await act(async () => {
- actions.clickNextButton();
- });
-
- const { version, template, name, indexPatterns, _kbnMeta, order } = legacyTemplateToEdit;
-
- 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/__mocks__/@kbn/code-editor/index.tsx b/x-pack/platform/plugins/shared/index_management/__mocks__/@kbn/code-editor/index.tsx
new file mode 100644
index 0000000000000..769fc015f04f7
--- /dev/null
+++ b/x-pack/platform/plugins/shared/index_management/__mocks__/@kbn/code-editor/index.tsx
@@ -0,0 +1,53 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+import React from 'react';
+
+type AnyRecord = Record;
+
+const actual = jest.requireActual('@kbn/code-editor') as AnyRecord;
+
+const getTestSubj = (props: AnyRecord) => {
+ const testSubj = props['data-test-subj'];
+ return typeof testSubj === 'string' && testSubj.length ? testSubj : 'mockCodeEditor';
+};
+
+/**
+ * Lightweight CodeEditor replacement for JSDOM tests.
+ *
+ * Goals:
+ * - Avoid Monaco + portals (EuiPortal) warnings and open-handle leaks.
+ * - Preserve the module surface by spreading the real module exports.
+ * - Provide a stable input surface; tests can assert via `data-test-subj`.
+ *
+ * Supported onChange shapes:
+ * - Simple string: `onChange()`
+ */
+const MockedCodeEditor = (props: AnyRecord) => {
+ const value = props.value;
+ const onChange = props.onChange;
+
+ return (
+ ) => {
+ if (typeof onChange !== 'function') return;
+
+ const nextValue = e.currentTarget.value ?? e.target.value ?? '';
+ // Most call sites expect CodeEditor to behave like an and pass the text value.
+ (onChange as (arg: unknown) => void)(nextValue);
+ }}
+ />
+ );
+};
+
+module.exports = {
+ ...actual,
+ CodeEditor: MockedCodeEditor,
+};
diff --git a/x-pack/platform/plugins/shared/index_management/moon.yml b/x-pack/platform/plugins/shared/index_management/moon.yml
index 4555b65d0bee9..3d292dbc917ea 100644
--- a/x-pack/platform/plugins/shared/index_management/moon.yml
+++ b/x-pack/platform/plugins/shared/index_management/moon.yml
@@ -82,6 +82,7 @@ tags:
fileGroups:
src:
- __jest__/**/*
+ - __mocks__/**/*
- common/**/*
- public/**/*
- server/**/*
diff --git a/x-pack/platform/plugins/shared/index_management/public/application/components/mappings_editor/__jest__/client_integration/datatypes/index.ts b/x-pack/platform/plugins/shared/index_management/public/application/components/mappings_editor/__jest__/client_integration/datatypes/index.ts
deleted file mode 100644
index c44ab9fcb4627..0000000000000
--- a/x-pack/platform/plugins/shared/index_management/public/application/components/mappings_editor/__jest__/client_integration/datatypes/index.ts
+++ /dev/null
@@ -1,9 +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.
- */
-
-export { defaultShapeParameters } from './shape_datatype.test';
-export { defaultTextParameters } from './text_datatype.test';
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 97054e71f14ed..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,427 +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 { act } from 'react-dom/test-utils';
-
-import type { MappingsEditorTestBed } from '../helpers';
-import { componentHelpers, kibanaVersion } from '../helpers';
-import { getFieldConfig } from '../../../lib';
-
-const { setup, getMappingsEditorDataFactory } = componentHelpers.mappingsEditor;
-
-// Parameters automatically added to the text datatype when saved (with the default values)
-export const defaultTextParameters = {
- type: 'text',
- eager_global_ordinals: false,
- fielddata: false,
- index: true,
- index_options: 'positions',
- index_phrases: false,
- norms: true,
- store: false,
-};
-
-// FLAKY: https://github.com/elastic/kibana/issues/239817
-// FLAKY: https://github.com/elastic/kibana/issues/239818
-describe.skip('Mappings editor: text datatype', () => {
- /**
- * Variable to store the mappings data forwarded to the consumer component
- */
- let data: any;
- let onChangeHandler: jest.Mock = jest.fn();
- let getMappingsEditorData = getMappingsEditorDataFactory(onChangeHandler);
- let testBed: MappingsEditorTestBed;
-
- beforeAll(() => {
- jest.useFakeTimers({ legacyFakeTimers: true });
- });
-
- afterAll(() => {
- jest.useRealTimers();
- });
-
- beforeEach(() => {
- onChangeHandler = jest.fn();
- getMappingsEditorData = getMappingsEditorDataFactory(onChangeHandler);
- });
-
- test('initial view and default parameters values', async () => {
- const defaultMappings = {
- properties: {
- myField: {
- type: 'text',
- },
- },
- };
-
- await act(async () => {
- testBed = setup({ value: defaultMappings, onChange: onChangeHandler });
- });
- testBed.component.update();
-
- const {
- component,
- exists,
- actions: { startEditField, updateFieldName, getToggleValue, updateFieldAndCloseFlyout },
- } = testBed;
-
- // Open the flyout to edit the field
- await startEditField('myField');
-
- // Update the name of the field
- await updateFieldName('updatedField');
-
- // It should have searchable ("index" param) active by default
- const indexFieldConfig = getFieldConfig('index');
- expect(getToggleValue('indexParameter.formRowToggle')).toBe(indexFieldConfig.defaultValue);
-
- if (kibanaVersion.major < 7) {
- expect(exists('boostParameterToggle')).toBe(true);
- } else {
- // Since 8.x the boost parameter is deprecated
- expect(exists('boostParameterToggle')).toBe(false);
- }
-
- // Save the field and close the flyout
- await updateFieldAndCloseFlyout();
-
- // It should have the default parameters values added
- const updatedMappings = {
- properties: {
- updatedField: {
- ...defaultTextParameters,
- },
- },
- };
-
- ({ data } = await getMappingsEditorData(component));
- expect(data).toEqual(updatedMappings);
- });
-
- test('analyzer parameter: default values', async () => {
- const defaultMappings = {
- _meta: {},
- _source: {},
- properties: {
- myField: {
- type: 'text',
- // Should have 2 dropdown selects:
- // The first one set to 'language' and the second one set to 'french
- search_quote_analyzer: 'french',
- },
- },
- };
-
- await act(async () => {
- testBed = setup({ value: defaultMappings, onChange: onChangeHandler });
- });
- testBed.component.update();
-
- const {
- component,
- find,
- exists,
- form: { selectCheckBox, setSelectValue },
- actions: {
- startEditField,
- updateFieldName,
- getCheckboxValue,
- showAdvancedSettings,
- updateFieldAndCloseFlyout,
- },
- } = testBed;
- const fieldToEdit = 'myField';
- const newFieldName = 'updatedField';
-
- // Start edit, update the name only, and save to have all the default values
- await startEditField(fieldToEdit);
- await showAdvancedSettings();
- await updateFieldName(newFieldName);
- await updateFieldAndCloseFlyout();
-
- expect(exists('mappingsEditorFieldEdit')).toBe(false);
-
- ({ data } = await getMappingsEditorData(component));
-
- let updatedMappings: any = {
- ...defaultMappings,
- properties: {
- updatedField: {
- ...defaultMappings.properties.myField,
- ...defaultTextParameters,
- },
- },
- };
- expect(data).toEqual(updatedMappings);
-
- // Re-open the edit panel
- await startEditField(newFieldName);
- await showAdvancedSettings();
-
- // When no analyzer is defined, defaults to "Index default"
- let indexAnalyzerValue = find('indexAnalyzer.select').props().value;
- expect(indexAnalyzerValue).toEqual('index_default');
-
- const searchQuoteAnalyzerSelects = find('searchQuoteAnalyzer.select');
-
- expect(searchQuoteAnalyzerSelects.length).toBe(2);
- expect(searchQuoteAnalyzerSelects.at(0).props().value).toBe('language');
- expect(searchQuoteAnalyzerSelects.at(1).props().value).toBe(
- defaultMappings.properties.myField.search_quote_analyzer
- );
-
- // When no "search_analyzer" is defined, the checkBox should be checked
- let isUseSameAnalyzerForSearchChecked = getCheckboxValue(
- 'useSameAnalyzerForSearchCheckBox.input'
- );
- expect(isUseSameAnalyzerForSearchChecked).toBe(true);
-
- // And the search analyzer select should not exist
- expect(exists('searchAnalyzer')).toBe(false);
-
- // Uncheck the "Use same analyzer for search" checkbox and make sure the dedicated select appears
- await act(async () => {
- selectCheckBox('useSameAnalyzerForSearchCheckBox.input', false);
- });
- component.update();
-
- expect(exists('searchAnalyzer.select')).toBe(true);
-
- let searchAnalyzerValue = find('searchAnalyzer.select').props().value;
- expect(searchAnalyzerValue).toEqual('index_default');
-
- await act(async () => {
- // Change the value of the 3 analyzers
- setSelectValue('indexAnalyzer.select', 'standard', false);
- setSelectValue('searchAnalyzer.select', 'simple', false);
- setSelectValue(find('searchQuoteAnalyzer.select').at(0), 'whitespace', false);
- });
-
- await updateFieldAndCloseFlyout();
-
- updatedMappings = {
- ...updatedMappings,
- properties: {
- updatedField: {
- ...updatedMappings.properties.updatedField,
- analyzer: 'standard',
- search_analyzer: 'simple',
- search_quote_analyzer: 'whitespace',
- },
- },
- };
-
- ({ data } = await getMappingsEditorData(component));
- expect(data).toEqual(updatedMappings);
-
- // Re-open the flyout and make sure the select have the correct updated value
- await startEditField(newFieldName);
- await showAdvancedSettings();
-
- isUseSameAnalyzerForSearchChecked = getCheckboxValue('useSameAnalyzerForSearchCheckBox.input');
- expect(isUseSameAnalyzerForSearchChecked).toBe(false);
-
- indexAnalyzerValue = find('indexAnalyzer.select').props().value;
- searchAnalyzerValue = find('searchAnalyzer.select').props().value;
- const searchQuoteAnalyzerValue = find('searchQuoteAnalyzer.select').props().value;
-
- expect(indexAnalyzerValue).toBe('standard');
- expect(searchAnalyzerValue).toBe('simple');
- expect(searchQuoteAnalyzerValue).toBe('whitespace');
- });
-
- test('analyzer parameter: custom analyzer (external plugin)', async () => {
- const defaultMappings = {
- _meta: {},
- _source: {},
- properties: {
- myField: {
- type: 'text',
- analyzer: 'myCustomIndexAnalyzer',
- search_analyzer: 'myCustomSearchAnalyzer',
- search_quote_analyzer: 'myCustomSearchQuoteAnalyzer',
- },
- },
- };
-
- let updatedMappings: any = {
- ...defaultMappings,
- properties: {
- myField: {
- ...defaultMappings.properties.myField,
- ...defaultTextParameters,
- },
- },
- };
-
- await act(async () => {
- testBed = setup({ value: defaultMappings, onChange: onChangeHandler });
- });
- testBed.component.update();
-
- const {
- find,
- exists,
- component,
- form: { setInputValue, setSelectValue },
- actions: { startEditField, showAdvancedSettings, updateFieldAndCloseFlyout },
- } = testBed;
- const fieldToEdit = 'myField';
-
- await startEditField(fieldToEdit);
- await showAdvancedSettings();
-
- expect(exists('indexAnalyzer-custom')).toBe(true);
- expect(exists('searchAnalyzer-custom')).toBe(true);
- expect(exists('searchQuoteAnalyzer-custom')).toBe(true);
-
- const indexAnalyzerValue = find('indexAnalyzer-custom.input').props().value;
- const searchAnalyzerValue = find('searchAnalyzer-custom.input').props().value;
- const searchQuoteAnalyzerValue = find('searchQuoteAnalyzer-custom.input').props().value;
-
- expect(indexAnalyzerValue).toBe(defaultMappings.properties.myField.analyzer);
- expect(searchAnalyzerValue).toBe(defaultMappings.properties.myField.search_analyzer);
- expect(searchQuoteAnalyzerValue).toBe(defaultMappings.properties.myField.search_quote_analyzer);
-
- const updatedIndexAnalyzer = 'newCustomIndexAnalyzer';
- const updatedSearchAnalyzer = 'whitespace';
-
- await act(async () => {
- // Change the index analyzer to another custom one
- setInputValue('indexAnalyzer-custom.input', updatedIndexAnalyzer);
- });
-
- await act(async () => {
- // Change the search analyzer to a built-in analyzer
- find('searchAnalyzer-toggleCustomButton').simulate('click');
- });
- component.update();
-
- await act(async () => {
- setSelectValue('searchAnalyzer.select', updatedSearchAnalyzer, false);
- });
-
- await act(async () => {
- // Change the searchQuote to use built-in analyzer
- // By default it means using the "index default"
- find('searchQuoteAnalyzer-toggleCustomButton').simulate('click');
- });
-
- await updateFieldAndCloseFlyout();
-
- ({ data } = await getMappingsEditorData(component));
-
- updatedMappings = {
- ...updatedMappings,
- properties: {
- myField: {
- ...updatedMappings.properties.myField,
- analyzer: updatedIndexAnalyzer,
- search_analyzer: updatedSearchAnalyzer,
- search_quote_analyzer: undefined, // Index default means not declaring the analyzer
- },
- },
- };
-
- expect(data).toEqual(updatedMappings);
- });
-
- test('analyzer parameter: custom analyzer (from index settings)', async () => {
- const indexSettings = {
- analysis: {
- analyzer: {
- // eslint-disable-next-line @typescript-eslint/naming-convention
- customAnalyzer_1: {},
- // eslint-disable-next-line @typescript-eslint/naming-convention
- customAnalyzer_2: {},
- // eslint-disable-next-line @typescript-eslint/naming-convention
- customAnalyzer_3: {},
- },
- },
- };
-
- const customAnalyzers = Object.keys(indexSettings.analysis.analyzer);
-
- const defaultMappings = {
- properties: {
- myField: {
- type: 'text',
- analyzer: customAnalyzers[0],
- },
- },
- };
-
- let updatedMappings: any = {
- ...defaultMappings,
- properties: {
- myField: {
- ...defaultMappings.properties.myField,
- ...defaultTextParameters,
- },
- },
- };
-
- await act(async () => {
- testBed = setup({
- value: defaultMappings,
- onChange: onChangeHandler,
- indexSettings,
- });
- });
- testBed.component.update();
-
- const {
- component,
- find,
- form: { setSelectValue },
- actions: { startEditField, showAdvancedSettings, updateFieldAndCloseFlyout },
- } = testBed;
- const fieldToEdit = 'myField';
-
- await startEditField(fieldToEdit);
- await showAdvancedSettings();
-
- // It should have 2 selects
- const indexAnalyzerSelects = find('indexAnalyzer.select');
-
- expect(indexAnalyzerSelects.length).toBe(2);
- expect(indexAnalyzerSelects.at(0).props().value).toBe('custom');
- expect(indexAnalyzerSelects.at(1).props().value).toBe(
- defaultMappings.properties.myField.analyzer
- );
-
- // Access the list of option of the second dropdown select
- const subSelectOptions = indexAnalyzerSelects
- .at(1)
- .find('option')
- .map((wrapper) => wrapper.text());
-
- expect(subSelectOptions).toEqual(customAnalyzers);
-
- await act(async () => {
- // Change the custom analyzer dropdown to another one from the index settings
- setSelectValue(find('indexAnalyzer.select').at(1), customAnalyzers[2], false);
- });
- component.update();
-
- await updateFieldAndCloseFlyout();
-
- ({ data } = await getMappingsEditorData(component));
-
- updatedMappings = {
- ...updatedMappings,
- properties: {
- myField: {
- ...updatedMappings.properties.myField,
- analyzer: customAnalyzers[2],
- },
- },
- };
-
- expect(data).toEqual(updatedMappings);
- });
-});
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 556f886906bda..0000000000000
--- a/x-pack/platform/plugins/shared/index_management/public/application/components/mappings_editor/__jest__/client_integration/edit_field.test.tsx
+++ /dev/null
@@ -1,151 +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 { act } from 'react-dom/test-utils';
-
-import type { MappingsEditorTestBed } from './helpers';
-import { componentHelpers } from './helpers';
-import { defaultTextParameters, defaultShapeParameters } from './datatypes';
-const { setup, getMappingsEditorDataFactory } = componentHelpers.mappingsEditor;
-
-describe('Mappings editor: edit field', () => {
- /**
- * Variable to store the mappings data forwarded to the consumer component
- */
- let data: any;
- let onChangeHandler: jest.Mock = jest.fn();
- let getMappingsEditorData = getMappingsEditorDataFactory(onChangeHandler);
- let testBed: MappingsEditorTestBed;
-
- beforeAll(() => {
- jest.useFakeTimers({ legacyFakeTimers: true });
- });
-
- afterAll(() => {
- jest.useRealTimers();
- });
-
- beforeEach(() => {
- onChangeHandler = jest.fn();
- getMappingsEditorData = getMappingsEditorDataFactory(onChangeHandler);
- });
-
- 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' },
- },
- },
- },
- },
- },
- };
-
- await act(async () => {
- testBed = setup({ value: defaultMappings, onChange: onChangeHandler });
- });
- testBed.component.update();
-
- await testBed.actions.expandAllFieldsAndReturnMetadata();
-
- const {
- find,
- actions: { startEditField },
- } = testBed;
- // Open the flyout to edit the field
- await startEditField('user.address.street');
-
- // It should have the correct title
- expect(find('mappingsEditorFieldEdit.flyoutTitle').text()).toEqual(`Edit field 'street'`);
-
- // It should have the correct field path
- expect(find('mappingsEditorFieldEdit.fieldPath').text()).toEqual('user > address > street');
-
- // The advanced settings should be hidden initially
- expect(find('mappingsEditorFieldEdit.advancedSettings').props().style.display).toEqual('none');
- });
-
- test('should update form parameters when changing the field datatype', async () => {
- const defaultMappings = {
- properties: {
- userName: {
- ...defaultTextParameters,
- },
- },
- };
-
- await act(async () => {
- testBed = setup({ value: defaultMappings, onChange: onChangeHandler });
- });
- testBed.component.update();
-
- const {
- find,
- exists,
- component,
- actions: { startEditField, updateFieldAndCloseFlyout },
- } = testBed;
-
- expect(exists('userNameField' as any)).toBe(true);
- // Open the flyout, change the field type and save it
- await startEditField('userName');
-
- // Change the field type
- await act(async () => {
- find('mappingsEditorFieldEdit.fieldType').simulate('change', [
- { label: 'Shape', value: defaultShapeParameters.type },
- ]);
- });
-
- await updateFieldAndCloseFlyout();
-
- ({ data } = await getMappingsEditorData(component));
-
- const updatedMappings = {
- ...defaultMappings,
- properties: {
- userName: {
- ...defaultShapeParameters,
- },
- },
- };
-
- expect(data).toEqual(updatedMappings);
- });
-
- test('should have Update button enabled only when changes are made', async () => {
- const defaultMappings = {
- properties: {
- myField: {
- type: 'text',
- },
- },
- };
-
- await act(async () => {
- testBed = setup({ value: defaultMappings, onChange: onChangeHandler });
- });
- testBed.component.update();
-
- await testBed.actions.expandAllFieldsAndReturnMetadata();
-
- const {
- actions: { startEditField, isUpdateButtonDisabled, updateFieldName },
- } = testBed;
- // Open the flyout to edit the field
- await startEditField('myField');
- expect(isUpdateButtonDisabled()).toBe(true);
- await updateFieldName('updatedField');
- expect(isUpdateButtonDisabled()).toBe(false);
- });
-});
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/public/application/components/mappings_editor/components/document_fields/field_parameters/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
new file mode 100644
index 0000000000000..f87f431d8a5dd
--- /dev/null
+++ b/x-pack/platform/plugins/shared/index_management/public/application/components/mappings_editor/components/document_fields/field_parameters/select_inference_id.test.tsx
@@ -0,0 +1,519 @@
+/*
+ * 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 { 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 { Form, useForm } from '../../../shared_imports';
+import { useLoadInferenceEndpoints } from '../../../../../services/api';
+import { SelectInferenceId } from './select_inference_id';
+
+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}
+
+ ),
+ };
+});
+
+jest.mock('@kbn/inference-endpoint-ui-common', () => {
+ const SERVICE_PROVIDERS = {
+ elastic: { name: 'Elastic' },
+ openai: { name: 'OpenAI' },
+ };
+
+ const MockInferenceFlyoutWrapper = ({
+ onFlyoutClose,
+ onSubmitSuccess,
+ allowedTaskTypes,
+ }: {
+ onFlyoutClose: () => void;
+ onSubmitSuccess: (id: string) => void;
+ http?: unknown;
+ toasts?: unknown;
+ isEdit?: boolean;
+ enforceAdaptiveAllocations?: boolean;
+ allowedTaskTypes?: InferenceTaskType[];
+ }) => (
+
+
+
+ {allowedTaskTypes && (
+
{allowedTaskTypes.join(',')}
+ )}
+
+ );
+
+ return {
+ __esModule: true,
+ default: MockInferenceFlyoutWrapper,
+ SERVICE_PROVIDERS,
+ };
+});
+
+jest.mock('../../../../../services/api', () => ({
+ ...jest.requireActual('../../../../../services/api'),
+ useLoadInferenceEndpoints: jest.fn(),
+}));
+
+const mockNavigateToUrl = jest.fn();
+
+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') })),
+ },
+ },
+ },
+ },
+ })),
+}));
+
+const DEFAULT_ENDPOINTS: InferenceAPIConfigResponse[] = [
+ {
+ 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,
+ });
+
+ 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 () => {
+ await renderSelectInferenceId();
+
+ expect(await screen.findByTestId('selectInferenceId')).toBeInTheDocument();
+ expect(await screen.findByTestId('inferenceIdButton')).toBeInTheDocument();
+ });
+
+ it('SHOULD display selected endpoint in button', async () => {
+ await renderSelectInferenceId({ initialValue: '' });
+
+ const button = await screen.findByTestId('inferenceIdButton');
+ expect(button).toHaveTextContent(defaultInferenceEndpoints.ELSER);
+ });
+ });
+
+ describe('WHEN popover button is clicked', () => {
+ beforeEach(() => {
+ setupInferenceEndpointsMocks();
+ });
+
+ it('SHOULD open popover with management buttons', async () => {
+ await renderSelectInferenceId();
+
+ await user.click(await screen.findByTestId('inferenceIdButton'));
+
+ expect(await screen.findByTestId('createInferenceEndpointButton')).toBeInTheDocument();
+ expect(await screen.findByTestId('manageInferenceEndpointButton')).toBeInTheDocument();
+ });
+
+ describe('AND button is clicked again', () => {
+ it('SHOULD close the popover', async () => {
+ await renderSelectInferenceId();
+
+ const toggle = await screen.findByTestId('inferenceIdButton');
+
+ await user.click(toggle);
+ expect(await screen.findByTestId('createInferenceEndpointButton')).toBeInTheDocument();
+
+ await user.click(toggle);
+ await waitFor(() => {
+ expect(screen.queryByTestId('createInferenceEndpointButton')).not.toBeInTheDocument();
+ });
+ });
+ });
+
+ describe('AND "Add inference endpoint" button is clicked', () => {
+ it('SHOULD close popover', async () => {
+ await renderSelectInferenceId();
+
+ await user.click(await screen.findByTestId('inferenceIdButton'));
+ expect(await screen.findByTestId('createInferenceEndpointButton')).toBeInTheDocument();
+
+ await user.click(await screen.findByTestId('createInferenceEndpointButton'));
+ await waitFor(() => {
+ expect(screen.queryByTestId('createInferenceEndpointButton')).not.toBeInTheDocument();
+ });
+ });
+ });
+ });
+
+ describe('WHEN endpoint is created optimistically', () => {
+ beforeEach(() => {
+ setupInferenceEndpointsMocks();
+ });
+
+ it('SHOULD display newly created endpoint even if not in loaded list', async () => {
+ await renderSelectInferenceId({ initialValue: 'newly-created-endpoint' });
+
+ await user.click(await screen.findByTestId('inferenceIdButton'));
+
+ const newEndpoint = await screen.findByTestId('custom-inference_newly-created-endpoint');
+ expect(newEndpoint).toBeInTheDocument();
+ expect(newEndpoint).toHaveAttribute('aria-checked', 'true');
+ });
+ });
+
+ describe('WHEN flyout is opened', () => {
+ beforeEach(() => {
+ setupInferenceEndpointsMocks();
+ });
+
+ it('SHOULD show flyout when "Add inference endpoint" is clicked', async () => {
+ await renderSelectInferenceId();
+
+ await user.click(await screen.findByTestId('inferenceIdButton'));
+ await user.click(await screen.findByTestId('createInferenceEndpointButton'));
+
+ expect(await screen.findByTestId('inference-flyout-wrapper')).toBeInTheDocument();
+ });
+
+ it('SHOULD pass allowedTaskTypes to restrict endpoint creation to compatible types', async () => {
+ await renderSelectInferenceId();
+
+ await user.click(await screen.findByTestId('inferenceIdButton'));
+ await user.click(await screen.findByTestId('createInferenceEndpointButton'));
+
+ const allowedTaskTypes = await screen.findByTestId('mock-allowed-task-types');
+ expect(allowedTaskTypes).toHaveTextContent('text_embedding,sparse_embedding');
+ });
+
+ describe('AND flyout close is triggered', () => {
+ it('SHOULD close the flyout', async () => {
+ await renderSelectInferenceId();
+
+ await user.click(await screen.findByTestId('inferenceIdButton'));
+ await user.click(await screen.findByTestId('createInferenceEndpointButton'));
+ expect(await screen.findByTestId('inference-flyout-wrapper')).toBeInTheDocument();
+
+ await user.click(await screen.findByTestId('mock-flyout-close'));
+
+ expect(screen.queryByTestId('inference-flyout-wrapper')).not.toBeInTheDocument();
+ });
+ });
+
+ describe('AND endpoint is successfully created', () => {
+ it('SHOULD call resendRequest when submitted', async () => {
+ await renderSelectInferenceId();
+
+ await user.click(await screen.findByTestId('inferenceIdButton'));
+ await user.click(await screen.findByTestId('createInferenceEndpointButton'));
+
+ expect(await screen.findByTestId('inference-flyout-wrapper')).toBeInTheDocument();
+
+ await user.click(await screen.findByTestId('mock-flyout-submit'));
+
+ expect(mockResendRequest).toHaveBeenCalledTimes(1);
+ });
+ });
+ });
+
+ describe('WHEN endpoint is selected from list', () => {
+ beforeEach(() => {
+ setupInferenceEndpointsMocks();
+ });
+
+ it('SHOULD update form value with selected endpoint', async () => {
+ await renderSelectInferenceId();
+
+ await user.click(await screen.findByTestId('inferenceIdButton'));
+
+ const endpoint1 = await screen.findByTestId('custom-inference_endpoint-1');
+ await user.click(endpoint1);
+
+ const button = await screen.findByTestId('inferenceIdButton');
+ expect(button).toHaveTextContent('endpoint-1');
+ });
+ });
+
+ describe('WHEN user searches for endpoints', () => {
+ beforeEach(() => {
+ setupInferenceEndpointsMocks();
+ });
+
+ it('SHOULD filter endpoints based on search input', async () => {
+ await renderSelectInferenceId();
+
+ await user.click(await screen.findByTestId('inferenceIdButton'));
+
+ const searchInput = await screen.findByRole('combobox', {
+ name: /Existing endpoints/i,
+ });
+ 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();
+ });
+ });
+
+ describe('WHEN endpoints are loading', () => {
+ it('SHOULD display loading spinner', async () => {
+ setupInferenceEndpointsMocks({ data: undefined, isLoading: true, error: null });
+
+ await renderSelectInferenceId();
+
+ await user.click(await screen.findByTestId('inferenceIdButton'));
+ await screen.findByTestId('createInferenceEndpointButton');
+
+ const progressBars = screen.getAllByRole('progressbar');
+ expect(progressBars.length).toBeGreaterThan(0);
+ });
+ });
+
+ describe('WHEN endpoints list is empty', () => {
+ it('SHOULD not set default value', async () => {
+ setupInferenceEndpointsMocks({ data: [], isLoading: false, error: null });
+
+ await renderSelectInferenceId({ initialValue: '' });
+
+ const button = screen.getByTestId('inferenceIdButton');
+ expect(button).toHaveTextContent('No inference endpoint selected');
+ });
+
+ it('SHOULD display no endpoint selected message when no endpoints are returned', async () => {
+ setupInferenceEndpointsMocks({ data: [], isLoading: false, error: null });
+
+ await renderSelectInferenceId({ initialValue: '' });
+
+ const button = screen.getByTestId('inferenceIdButton');
+ expect(button).toHaveTextContent('No inference endpoint selected');
+ });
+ });
+
+ describe('WHEN only incompatible endpoints are available', () => {
+ const incompatibleEndpoints: InferenceAPIConfigResponse[] = [
+ { inference_id: 'incompatible-1', task_type: 'completion' },
+ { inference_id: 'incompatible-2', task_type: 'rerank' },
+ ] as InferenceAPIConfigResponse[];
+
+ beforeEach(() => {
+ setupInferenceEndpointsMocks({ data: incompatibleEndpoints });
+ });
+
+ it('SHOULD not display incompatible endpoints in list', async () => {
+ await renderSelectInferenceId({ initialValue: '' });
+
+ await user.click(await screen.findByTestId('inferenceIdButton'));
+ await screen.findByTestId('createInferenceEndpointButton');
+
+ expect(screen.queryByTestId('custom-inference_incompatible-1')).not.toBeInTheDocument();
+ expect(screen.queryByTestId('custom-inference_incompatible-2')).not.toBeInTheDocument();
+ });
+ });
+
+ describe('WHEN API returns error', () => {
+ it('SHOULD handle error gracefully and still render UI', async () => {
+ setupInferenceEndpointsMocks({
+ data: [],
+ isLoading: false,
+ error: {
+ error: 'Failed to load endpoints',
+ message: 'Failed to load endpoints',
+ },
+ });
+
+ await renderSelectInferenceId();
+
+ expect(screen.getByTestId('selectInferenceId')).toBeInTheDocument();
+
+ await user.click(await screen.findByTestId('inferenceIdButton'));
+ expect(await screen.findByTestId('createInferenceEndpointButton')).toBeInTheDocument();
+
+ expect(screen.queryByTestId('custom-inference_endpoint-1')).not.toBeInTheDocument();
+ expect(screen.queryByTestId('custom-inference_endpoint-2')).not.toBeInTheDocument();
+ });
+ });
+
+ describe('WHEN component mounts with empty value', () => {
+ it('SHOULD automatically select default endpoint', async () => {
+ setupInferenceEndpointsMocks();
+
+ await renderSelectInferenceId({ initialValue: '' });
+
+ const button = await screen.findByTestId('inferenceIdButton');
+ await waitFor(() => expect(button).toHaveTextContent(defaultInferenceEndpoints.ELSER));
+ });
+
+ describe('AND .elser-2-elasticsearch is available', () => {
+ it('SHOULD prioritize .elser-2-elasticsearch over lower-priority endpoints', async () => {
+ setupInferenceEndpointsMocks({
+ data: [
+ {
+ inference_id: defaultInferenceEndpoints.ELSER,
+ task_type: 'sparse_embedding',
+ service: 'elastic',
+ service_settings: { model_id: 'elser' },
+ },
+ {
+ inference_id: defaultInferenceEndpoints.ELSER_IN_EIS_INFERENCE_ID,
+ task_type: 'sparse_embedding',
+ service: 'elastic',
+ service_settings: { model_id: 'elser-2-elastic' },
+ },
+ {
+ inference_id: 'endpoint-1',
+ task_type: 'text_embedding',
+ service: 'openai',
+ service_settings: { model_id: 'text-embedding-3-large' },
+ },
+ ] as InferenceAPIConfigResponse[],
+ });
+
+ await renderSelectInferenceId({ initialValue: '' });
+
+ const button = await screen.findByTestId('inferenceIdButton');
+ await waitFor(() =>
+ expect(button).toHaveTextContent(defaultInferenceEndpoints.ELSER_IN_EIS_INFERENCE_ID)
+ );
+ });
+ });
+ });
+});
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
new file mode 100644
index 0000000000000..63d7f522bd967
--- /dev/null
+++ b/x-pack/platform/plugins/shared/index_management/public/application/components/mappings_editor/mappings_editor.test.tsx
@@ -0,0 +1,1003 @@
+/*
+ * 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, 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 { 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 { 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(),
+ };
+});
+
+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: {
+ addError: jest.fn(),
+ addSuccess: 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 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[] };
+ _meta?: Record;
+ _routing?: { required?: boolean };
+ [key: string]: unknown;
+ }
+
+ let data: TestMappings | undefined;
+ let onChangeHandler: jest.Mock = jest.fn();
+
+ type MappingsEditorProps = ComponentProps;
+
+ const setup = (
+ props: Partial,
+ ctx: unknown = {
+ config: { enableMappingsSourceFieldSection: true },
+ canUseSyntheticSource: true,
+ }
+ ) => {
+ return renderMappingsEditor({ onChange: onChangeHandler, ...props }, ctx);
+ };
+
+ const selectTab = async (tabName: string) => {
+ const tabMap: Record = {
+ fields: 'Mapped fields',
+ runtimeFields: 'Runtime fields',
+ templates: 'Dynamic templates',
+ advanced: 'Advanced options',
+ };
+ const tab = screen.getByRole('tab', { name: tabMap[tabName] });
+ fireEvent.click(tab);
+ await waitFor(() => expect(tab).toHaveAttribute('aria-selected', 'true'));
+ };
+
+ const addField = async (
+ name: string,
+ type: string,
+ subType?: string,
+ referenceField?: string
+ ) => {
+ // Fill name
+ const nameInput = screen.getByTestId('nameParameterInput');
+ fireEvent.change(nameInput, { target: { value: name } });
+
+ // 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');
+ fireEvent.change(subTypeInput, { target: { value: subType } });
+ }
+
+ if (referenceField !== undefined) {
+ // Wait for reference field to appear after semantic_text type is selected
+ const referenceSelect = await screen.findByTestId('referenceFieldSelectInput');
+ fireEvent.change(referenceSelect, { target: { value: referenceField } });
+ fireEvent.blur(referenceSelect);
+ }
+
+ const addButton = screen.getByTestId('addButton');
+ fireEvent.click(addButton);
+
+ // 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);
+
+ await waitFor(() => expect(screen.queryByTestId('createFieldForm')).not.toBeInTheDocument());
+ };
+
+ const updateJsonEditor = (testSubj: string, value: object) => {
+ // Strip prefix - form doesn't create hierarchical test subjects
+ const actualTestSubj = testSubj.replace(/^advancedConfiguration\./, '');
+ const editor = screen.getByTestId(actualTestSubj);
+ fireEvent.change(editor, { target: { value: JSON.stringify(value) } });
+ };
+
+ const getJsonEditorValue = (testSubj: string) => {
+ // Strip prefix - form doesn't create hierarchical test subjects
+ const actualTestSubj = testSubj.replace(/^advancedConfiguration\./, '');
+ const editor = screen.getByTestId(actualTestSubj);
+ const attributeValue = editor.getAttribute('data-currentvalue');
+ const inputValue = (editor as HTMLInputElement).value;
+ const value = typeof attributeValue === 'string' ? attributeValue : inputValue;
+
+ if (typeof value === 'string') {
+ try {
+ return JSON.parse(value);
+ } catch {
+ return { errorParsingJson: true };
+ }
+ }
+ return value;
+ };
+
+ const getToggleValue = (testSubj: string): boolean => {
+ // Strip 'advancedConfiguration.' prefix if present - form doesn't create hierarchical test subjects
+ const actualTestSubj = testSubj.replace(/^advancedConfiguration\./, '');
+
+ // Handle hierarchical test subjects like 'dynamicMappingsToggle.input'
+ if (actualTestSubj.includes('.')) {
+ const [formRowId, inputId] = actualTestSubj.split('.');
+ const formRow = screen.getByTestId(formRowId);
+ const toggle = within(formRow).getByTestId(inputId);
+ return toggle.getAttribute('aria-checked') === 'true';
+ }
+
+ const toggle = screen.getByTestId(actualTestSubj);
+ return toggle.getAttribute('aria-checked') === 'true';
+ };
+
+ const getComboBoxValue = (testSubj: string) => {
+ // Strip prefixes - sourceField is not hierarchical in DOM
+ const actualTestSubj = testSubj.replace(/^(advancedConfiguration|sourceField)\./, '');
+ const comboBoxContainer = screen.getByTestId(actualTestSubj);
+
+ // Real EuiComboBox renders selected options as pills with data-test-subj="euiComboBoxPill"
+ const pills = within(comboBoxContainer).queryAllByTestId('euiComboBoxPill');
+ return pills.map((pill) => pill.textContent || '');
+ };
+
+ const toggleEuiSwitch = async (testSubj: string) => {
+ // Strip prefix - form doesn't create hierarchical test subjects
+ const actualTestSubj = testSubj.replace(/^advancedConfiguration\./, '');
+
+ // Handle hierarchical test subjects like 'dynamicMappingsToggle.input'
+ let toggle;
+ if (actualTestSubj.includes('.')) {
+ const [formRowId, inputId] = actualTestSubj.split('.');
+ const formRow = screen.getByTestId(formRowId);
+ toggle = within(formRow).getByTestId(inputId);
+ } else {
+ toggle = screen.getByTestId(actualTestSubj);
+ }
+
+ fireEvent.click(toggle);
+ await waitFor(() => {
+ const newState = toggle.getAttribute('aria-checked');
+ expect(newState).toBeDefined();
+ });
+ };
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ onChangeHandler = jest.fn();
+ });
+
+ test('default behaviour', async () => {
+ const defaultMappings = {
+ properties: {
+ user: {
+ // No type defined for user
+ properties: {
+ name: { type: 'text' },
+ },
+ },
+ },
+ };
+
+ setup({ value: defaultMappings, onChange: onChangeHandler });
+
+ await screen.findByTestId('mappingsEditor');
+
+ const expectedMappings = {
+ ...defaultMappings,
+ properties: {
+ user: {
+ type: 'object', // Was not defined so it defaults to "object" type
+ ...defaultMappings.properties.user,
+ },
+ },
+ };
+
+ // Verify onChange was called with expected mappings
+ expect(onChangeHandler).toHaveBeenCalled();
+ const lastCall = onChangeHandler.mock.calls[onChangeHandler.mock.calls.length - 1][0];
+ data = lastCall.getData(lastCall.isValid ?? true);
+ expect(data).toEqual(expectedMappings);
+ });
+
+ describe('multiple mappings detection', () => {
+ test('should show a warning when multiple mappings are detected', async () => {
+ const value = {
+ type1: {
+ properties: {
+ name1: {
+ type: 'keyword',
+ },
+ },
+ },
+ type2: {
+ properties: {
+ name2: {
+ type: 'keyword',
+ },
+ },
+ },
+ };
+
+ setup({ onChange: onChangeHandler, value });
+
+ await screen.findByTestId('mappingsEditor');
+
+ expect(screen.getByTestId('mappingTypesDetectedCallout')).toBeInTheDocument();
+ expect(screen.queryByTestId('documentFields')).not.toBeInTheDocument();
+ });
+
+ test('should not show a warning when mappings a single-type', async () => {
+ const value = {
+ properties: {
+ name1: {
+ type: 'keyword',
+ },
+ },
+ };
+
+ setup({ onChange: onChangeHandler, value });
+
+ await screen.findByTestId('mappingsEditor');
+
+ expect(screen.queryByTestId('mappingTypesDetectedCallout')).not.toBeInTheDocument();
+ expect(screen.getByTestId('documentFields')).toBeInTheDocument();
+ });
+ });
+
+ describe('tabs', () => {
+ const defaultMappings = {
+ properties: {},
+ dynamic_templates: [{ before: 'foo' }],
+ };
+
+ const ctx = {
+ config: {
+ enableMappingsSourceFieldSection: false,
+ },
+ canUseSyntheticSource: false,
+ };
+
+ test('should have 4 tabs (fields, runtime, template, advanced settings)', async () => {
+ setup({ value: defaultMappings, onChange: onChangeHandler }, ctx);
+ await screen.findByTestId('mappingsEditor');
+
+ const tabs = screen.getAllByRole('tab');
+ const tabTexts = tabs.map((tab) => tab.textContent);
+
+ expect(tabTexts).toEqual([
+ 'Mapped fields',
+ 'Runtime fields',
+ 'Dynamic templates',
+ 'Advanced options',
+ ]);
+ });
+
+ 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
+ expect(screen.queryByTestId('fieldsListItem JohnField')).not.toBeInTheDocument();
+
+ await openCreateFieldForm();
+ const newField = { name: 'John', type: 'text' };
+ await addField(newField.name, newField.type);
+
+ // Switch away and back
+ await selectTab('templates');
+ await selectTab('fields');
+ expect(await screen.findByTestId('fieldsListItem JohnField')).toBeInTheDocument();
+ });
+
+ test('keeps dynamic templates edits when switching tabs', async () => {
+ setup({ value: defaultMappings, onChange: onChangeHandler }, ctx);
+ await screen.findByTestId('mappingsEditor');
+
+ await selectTab('templates');
+
+ const updatedValueTemplates = [{ after: 'bar' }];
+ updateJsonEditor('dynamicTemplatesEditor', updatedValueTemplates);
+ expect(getJsonEditorValue('dynamicTemplatesEditor')).toEqual(updatedValueTemplates);
+
+ // Switch to a lightweight tab and back (avoid rendering advanced options)
+ await selectTab('fields');
+ await selectTab('templates');
+
+ expect(getJsonEditorValue('dynamicTemplatesEditor')).toEqual(updatedValueTemplates);
+ });
+
+ test('keeps advanced settings edits when switching tabs', async () => {
+ setup({ value: defaultMappings, onChange: onChangeHandler }, ctx);
+ await screen.findByTestId('mappingsEditor');
+
+ await selectTab('advanced');
+
+ expect(getToggleValue('advancedConfiguration.dynamicMappingsToggle.input')).toBe(true);
+ expect(screen.queryByTestId('numericDetection')).toBeInTheDocument();
+
+ await toggleEuiSwitch('advancedConfiguration.dynamicMappingsToggle.input');
+
+ expect(getToggleValue('advancedConfiguration.dynamicMappingsToggle.input')).toBe(false);
+ expect(screen.queryByTestId('numericDetection')).not.toBeInTheDocument();
+
+ // Switch to a lightweight tab and back (avoid JSON editor work)
+ await selectTab('runtimeFields');
+ await selectTab('advanced');
+
+ 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(
+ {
+ value: { ...defaultMappings, dynamic_templates: [] }, // by default, the UI will provide an empty array for dynamic templates
+ onChange: onChangeHandler,
+ },
+ ctx
+ );
+
+ await screen.findByTestId('mappingsEditor');
+
+ // Navigate to dynamic templates tab and verify empty array
+ await selectTab('templates');
+ let templatesValue = getJsonEditorValue('dynamicTemplatesEditor');
+ expect(templatesValue).toEqual([]);
+
+ // Navigate to advanced tab
+ await selectTab('advanced');
+
+ // Navigate back to dynamic templates tab and verify empty array persists
+ await selectTab('templates');
+ templatesValue = getJsonEditorValue('dynamicTemplatesEditor');
+ expect(templatesValue).toEqual([]);
+ });
+ });
+
+ describe('component props', () => {
+ /**
+ * Note: the "indexSettings" prop will be tested along with the "analyzer" parameter on a text datatype field,
+ * as it is the only place where it is consumed by the mappings editor.
+ * The test that covers it is in the "text_datatype.test.tsx": "analyzer parameter: custom analyzer (from index settings)"
+ */
+ let defaultMappings: TestMappings;
+
+ const ctx = {
+ config: {
+ enableMappingsSourceFieldSection: true,
+ },
+ canUseSyntheticSource: true,
+ };
+
+ beforeEach(() => {
+ defaultMappings = {
+ dynamic: true,
+ numeric_detection: false,
+ date_detection: true,
+ properties: {
+ title: { type: 'text' },
+ address: {
+ type: 'object',
+ properties: {
+ street: { type: 'text' },
+ city: { type: 'text' },
+ },
+ },
+ },
+ dynamic_templates: [{ initial: 'value' }],
+ _source: {
+ enabled: true,
+ includes: ['field1', 'field2'],
+ excludes: ['field3'],
+ },
+ _meta: {
+ some: 'metaData',
+ },
+ _routing: {
+ required: false,
+ },
+ subobjects: true,
+ };
+ });
+
+ describe('props.value and props.onChange', () => {
+ beforeEach(async () => {
+ setup({ value: defaultMappings, onChange: onChangeHandler }, ctx);
+ await screen.findByTestId('mappingsEditor');
+ });
+
+ test('props.value => should prepopulate the editor data', async () => {
+ // Mapped fields
+ // Test that root-level mappings "properties" are rendered as root-level "DOM tree items"
+ const fieldElements = screen.getAllByTestId(/^fieldsListItem/);
+ const fields = fieldElements.map((el) => within(el).getByTestId(/fieldName/).textContent);
+ expect(fields.sort()).toEqual(Object.keys(defaultMappings.properties!).sort());
+
+ // Dynamic templates
+ await selectTab('templates');
+
+ // Test that dynamic templates JSON is rendered in the templates editor
+ const templatesValue = getJsonEditorValue('dynamicTemplatesEditor');
+ expect(templatesValue).toEqual(defaultMappings.dynamic_templates);
+
+ // Advanced settings
+ await selectTab('advanced');
+
+ const isDynamicMappingsEnabled = getToggleValue(
+ 'advancedConfiguration.dynamicMappingsToggle.input'
+ );
+ expect(isDynamicMappingsEnabled).toBe(defaultMappings.dynamic);
+
+ const isNumericDetectionEnabled = getToggleValue(
+ 'advancedConfiguration.numericDetection.input'
+ );
+ expect(isNumericDetectionEnabled).toBe(defaultMappings.numeric_detection);
+
+ expect(getComboBoxValue('sourceField.includesField')).toEqual(
+ defaultMappings._source!.includes
+ );
+ expect(getComboBoxValue('sourceField.excludesField')).toEqual(
+ defaultMappings._source!.excludes
+ );
+
+ const metaFieldValue = getJsonEditorValue('advancedConfiguration.metaField');
+ expect(metaFieldValue).toEqual(defaultMappings._meta);
+
+ const isRoutingRequired = getToggleValue(
+ 'advancedConfiguration.routingRequiredToggle.input'
+ );
+ expect(isRoutingRequired).toBe(defaultMappings._routing!.required);
+ });
+
+ 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);
+
+ const expectedMappings = {
+ ...defaultMappings,
+ properties: {
+ ...defaultMappings.properties,
+ [newField.name]: { type: 'text' },
+ },
+ };
+
+ 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(expectedMappings);
+ });
+ });
+
+ test('props.onChange() => forwards dynamic templates changes', async () => {
+ await selectTab('templates');
+
+ const updatedTemplatesValue = [{ someTemplateProp: 'updated' }];
+ updateJsonEditor('dynamicTemplatesEditor', updatedTemplatesValue);
+
+ const expectedMappings = {
+ ...defaultMappings,
+ dynamic_templates: 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(expectedMappings);
+ });
+ });
+
+ test('props.onChange() => forwards advanced settings changes', async () => {
+ await selectTab('advanced');
+
+ 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);
+ expect(data).toEqual(expectedMappings);
+ });
+ });
+ }); // Close inner describe for props.value and props.onChange
+
+ describe('semantic_text field tests', () => {
+ beforeEach(async () => {
+ setup({ value: defaultMappings, onChange: onChangeHandler }, ctx);
+ await screen.findByTestId('mappingsEditor');
+ });
+
+ test('updates mapping without inference id for semantic_text field', async () => {
+ let updatedMappings = { ...defaultMappings };
+
+ // Mapped fields
+ const addFieldButton = screen.getByTestId('addFieldButton');
+ fireEvent.click(addFieldButton);
+
+ await screen.findByTestId('createFieldForm');
+
+ const newField = { name: 'someNewField', type: 'semantic_text' };
+ await addField(newField.name, newField.type);
+
+ updatedMappings = {
+ ...updatedMappings,
+ properties: {
+ ...updatedMappings.properties,
+ [newField.name]: {
+ inference_id: defaultInferenceEndpoints.ELSER,
+ reference_field: '',
+ type: 'semantic_text',
+ },
+ },
+ };
+
+ 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);
+ });
+ });
+
+ test('updates mapping with reference field value for semantic_text field', async () => {
+ let updatedMappings = { ...defaultMappings };
+
+ // Mapped fields - Use an existing text field as reference
+ const addFieldButton = screen.getByTestId('addFieldButton');
+ fireEvent.click(addFieldButton);
+
+ await screen.findByTestId('createFieldForm');
+
+ const newField = {
+ name: 'someNewField',
+ type: 'semantic_text',
+ referenceField: 'title',
+ };
+
+ await addField(newField.name, newField.type, undefined, newField.referenceField);
+
+ updatedMappings = {
+ ...updatedMappings,
+ properties: {
+ ...updatedMappings.properties,
+ [newField.name]: {
+ inference_id: defaultInferenceEndpoints.ELSER,
+ reference_field: 'title',
+ type: 'semantic_text',
+ },
+ },
+ };
+
+ 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);
+ });
+ });
+ });
+
+ describe('props.indexMode sets the correct default value of _source field', () => {
+ it("defaults to 'stored' with 'standard' index mode prop", async () => {
+ setup(
+ {
+ value: { ...defaultMappings, _source: undefined },
+ onChange: onChangeHandler,
+ indexMode: 'standard',
+ },
+ ctx
+ );
+
+ await screen.findByTestId('mappingsEditor');
+
+ await selectTab('advanced');
+
+ const sourceValueButton = screen.getByTestId('sourceValueField');
+ expect(sourceValueButton.textContent).toContain('Stored _source');
+ });
+
+ (['logsdb', 'time_series'] as const).forEach((indexMode) => {
+ it(`defaults to 'synthetic' with ${indexMode} index mode prop when 'canUseSyntheticSource' is set to true`, async () => {
+ setup(
+ {
+ value: { ...defaultMappings, _source: undefined },
+ onChange: onChangeHandler,
+ indexMode,
+ },
+ ctx
+ );
+
+ await screen.findByTestId('mappingsEditor');
+
+ await selectTab('advanced');
+
+ await waitFor(() => {
+ const sourceValueButton = screen.getByTestId('sourceValueField');
+ expect(sourceValueButton.textContent).toContain('Synthetic _source');
+ });
+ });
+
+ it(`defaults to 'standard' with ${indexMode} index mode prop when 'canUseSyntheticSource' is set to true`, async () => {
+ setup(
+ {
+ value: { ...defaultMappings, _source: undefined },
+ onChange: onChangeHandler,
+ indexMode,
+ },
+ { ...ctx, canUseSyntheticSource: false }
+ );
+
+ await screen.findByTestId('mappingsEditor');
+
+ await selectTab('advanced');
+
+ const sourceValueButton = screen.getByTestId('sourceValueField');
+ expect(sourceValueButton.textContent).toContain('Stored _source');
+ });
+ });
+ });
+ });
+
+ describe('multi-fields support', () => {
+ it('allows multi-fields for most types', async () => {
+ const value = {
+ properties: {
+ name1: {
+ type: 'wildcard',
+ },
+ },
+ };
+
+ setup({ onChange: onChangeHandler, value });
+
+ await screen.findByTestId('mappingsEditor');
+
+ expect(screen.getByTestId('addMultiFieldButton')).toBeInTheDocument();
+ });
+
+ it('keeps the fields property in the field', async () => {
+ const value = {
+ properties: {
+ name1: {
+ type: 'wildcard',
+ fields: {
+ text: {
+ type: 'match_only_text',
+ },
+ },
+ },
+ },
+ };
+
+ setup({ onChange: onChangeHandler, value });
+
+ await screen.findByTestId('mappingsEditor');
+
+ // Verify onChange was called with the value including fields property
+ expect(onChangeHandler).toHaveBeenCalled();
+ const lastCall = onChangeHandler.mock.calls[onChangeHandler.mock.calls.length - 1][0];
+ data = lastCall.getData(lastCall.isValid ?? true);
+ expect(data).toEqual(value);
+ });
+ });
+ });
+
+ describe('datatypes', () => {
+ describe('other datatype', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ const onChangeHandler = jest.fn();
+
+ test('allow to add custom field type', async () => {
+ renderMappingsEditor({ onChange: onChangeHandler, indexSettings: {} });
+
+ await screen.findByTestId('mappingsEditor');
+
+ // Click "Add field" button to show the create field form
+ const addFieldButton = screen.getByTestId('addFieldButton');
+ fireEvent.click(addFieldButton);
+
+ const createForm = await screen.findByTestId('createFieldForm');
+
+ // Set field name
+ const nameInput = within(createForm).getByTestId('nameParameterInput');
+ fireEvent.change(nameInput, { target: { value: 'myField' } });
+
+ // 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 within(createForm).findByTestId('fieldSubType');
+
+ const customTypeInput = within(createForm).getByTestId('fieldSubType');
+ fireEvent.change(customTypeInput, { target: { value: 'customType' } });
+
+ // Click "Add" button to submit the field
+ const addButton = within(createForm).getByTestId('addButton');
+ fireEvent.click(addButton);
+
+ await waitFor(() => {
+ expect(onChangeHandler).toHaveBeenCalled();
+ });
+
+ const mappings = {
+ properties: {
+ myField: {
+ type: 'customType',
+ },
+ },
+ };
+
+ const [callData] = onChangeHandler.mock.calls[onChangeHandler.mock.calls.length - 1];
+ const actualMappings = callData.getData();
+ expect(actualMappings).toEqual(mappings);
+ });
+
+ test('allow to change a field type to a custom type', async () => {
+ const defaultMappings = {
+ properties: {
+ myField: {
+ type: 'text',
+ },
+ },
+ };
+
+ const updatedMappings = {
+ properties: {
+ myField: {
+ type: 'customType',
+ },
+ },
+ };
+
+ renderMappingsEditor({
+ value: defaultMappings,
+ onChange: onChangeHandler,
+ indexSettings: {},
+ });
+
+ await screen.findByTestId('mappingsEditor');
+
+ // Open the flyout to edit the field
+ const editButton = screen.getByTestId('editFieldButton');
+ fireEvent.click(editButton);
+
+ const flyout = await screen.findByTestId('mappingsEditorFieldEdit');
+
+ // 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' } });
+
+ // Save the field and close the flyout
+ const updateButton = within(flyout).getByTestId('editFieldUpdateButton');
+ fireEvent.click(updateButton);
+
+ await waitFor(() => {
+ expect(onChangeHandler).toHaveBeenCalled();
+ });
+
+ const [callData] = onChangeHandler.mock.calls[onChangeHandler.mock.calls.length - 1];
+ const actualMappings = callData.getData();
+ expect(actualMappings).toEqual(updatedMappings);
+ });
+ });
+ });
+});
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/template_form.tsx b/x-pack/platform/plugins/shared/index_management/public/application/components/template_form/template_form.tsx
index 7e61dcc9f44c1..4e430a3d026c7 100644
--- a/x-pack/platform/plugins/shared/index_management/public/application/components/template_form/template_form.tsx
+++ b/x-pack/platform/plugins/shared/index_management/public/application/components/template_form/template_form.tsx
@@ -18,7 +18,6 @@ import type { CommonWizardSteps } from '../shared';
import { StepSettingsContainer, StepMappingsContainer, StepAliasesContainer } from '../shared';
import { documentationService } from '../../services/documentation';
import { SectionError } from '../section_error';
-import { serializeAsESLifecycle } from '../../../../common/lib';
import type { SimulateTemplateProps, SimulateTemplateFilters } from '../index_templates';
import {
SimulateTemplateFlyoutContent,
@@ -26,6 +25,7 @@ import {
LegacyIndexTemplatesDeprecation,
} from '../index_templates';
import { StepLogisticsContainer, StepComponentContainer, StepReviewContainer } from './steps';
+import { buildTemplateFromWizardData } from './utils/build_template_from_wizard_data';
const { stripEmptyFields } = serializers;
const { FormWizard, FormWizardStep } = Forms;
@@ -185,57 +185,14 @@ export const TemplateForm = ({
>
) : null;
- /**
- * If no mappings, settings or aliases are defined, it is better to not send empty
- * object for those values.
- * This method takes care of that and other cleanup of empty fields.
- * @param template The template object to clean up
- */
- const cleanupTemplateObject = (template: TemplateDeserialized) => {
- const outputTemplate = { ...template };
-
- if (outputTemplate.template) {
- if (outputTemplate.template.settings === undefined) {
- delete outputTemplate.template.settings;
- }
- if (outputTemplate.template.mappings === undefined) {
- delete outputTemplate.template.mappings;
- }
- if (outputTemplate.template.aliases === undefined) {
- delete outputTemplate.template.aliases;
- }
- if (Object.keys(outputTemplate.template).length === 0) {
- delete outputTemplate.template;
- }
- if (outputTemplate.lifecycle) {
- delete outputTemplate.lifecycle;
- }
- }
-
- return outputTemplate;
- };
-
const buildTemplateObject = useCallback(
(initialTemplate: TemplateDeserialized) =>
(wizardData: WizardContent): TemplateDeserialized => {
- const outputTemplate = {
- ...wizardData.logistics,
- _kbnMeta: initialTemplate._kbnMeta,
- deprecated: initialTemplate.deprecated,
- composedOf: wizardData.components,
- template: {
- settings: wizardData.settings,
- mappings: wizardData.mappings,
- aliases: wizardData.aliases,
- lifecycle: wizardData.logistics.lifecycle
- ? serializeAsESLifecycle(wizardData.logistics.lifecycle)
- : undefined,
- ...(dataStreamOptions && { data_stream_options: dataStreamOptions }),
- },
- ignoreMissingComponentTemplates: initialTemplate.ignoreMissingComponentTemplates,
- };
-
- return cleanupTemplateObject(outputTemplate as TemplateDeserialized);
+ return buildTemplateFromWizardData({
+ initialTemplate,
+ wizardData,
+ dataStreamOptions,
+ });
},
[dataStreamOptions]
);
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
new file mode 100644
index 0000000000000..4f04644a5c841
--- /dev/null
+++ b/x-pack/platform/plugins/shared/index_management/public/application/components/template_form/utils/build_template_from_wizard_data.test.ts
@@ -0,0 +1,421 @@
+/*
+ * 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 type { TemplateDeserialized } from '../../../../../common';
+import { buildTemplateFromWizardData } from './build_template_from_wizard_data';
+
+describe('buildTemplateFromWizardData', () => {
+ test('preserves data stream configuration (including unknown keys)', () => {
+ const initialTemplate: TemplateDeserialized = {
+ name: 'index_template_without_mappings',
+ indexPatterns: ['indexPattern1'],
+ dataStream: {
+ hidden: true,
+ anyUnknownKey: 'should_be_kept',
+ },
+ indexMode: 'standard',
+ template: {},
+ allowAutoCreate: 'NO_OVERWRITE',
+ ignoreMissingComponentTemplates: [],
+ composedOf: [],
+ _kbnMeta: {
+ type: 'default',
+ hasDatastream: true,
+ isLegacy: false,
+ },
+ };
+
+ const { _kbnMeta: _ignoredKbnMeta, template: _ignoredTemplate, ...logistics } = initialTemplate;
+
+ const result = buildTemplateFromWizardData({
+ initialTemplate,
+ wizardData: {
+ logistics: {
+ ...logistics,
+ version: 1,
+ lifecycle: { enabled: true, value: 1, unit: 'd' },
+ },
+ settings: undefined,
+ mappings: {
+ properties: {
+ field_1: { type: 'text' },
+ },
+ },
+ aliases: undefined,
+ components: [],
+ },
+ });
+
+ expect(result.dataStream).toEqual({
+ 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({
+ enabled: true,
+ data_retention: '1d',
+ });
+ });
+
+ test('includes data stream options when provided', () => {
+ const initialTemplate: TemplateDeserialized = {
+ name: 'template_with_ds_options',
+ indexPatterns: ['indexPattern1'],
+ dataStream: {},
+ indexMode: 'standard',
+ template: {},
+ allowAutoCreate: 'NO_OVERWRITE',
+ ignoreMissingComponentTemplates: [],
+ composedOf: [],
+ _kbnMeta: {
+ type: 'default',
+ hasDatastream: true,
+ isLegacy: false,
+ },
+ };
+
+ const { _kbnMeta: _ignoredKbnMeta, template: _ignoredTemplate, ...logistics } = initialTemplate;
+
+ const result = buildTemplateFromWizardData({
+ initialTemplate,
+ wizardData: {
+ logistics,
+ settings: undefined,
+ mappings: undefined,
+ aliases: undefined,
+ components: [],
+ },
+ dataStreamOptions: { failure_store: { enabled: true } },
+ });
+
+ expect(result.template?.data_stream_options).toEqual({ failure_store: { enabled: true } });
+ });
+
+ test('keeps mappings when added in the wizard', () => {
+ const initialTemplate: TemplateDeserialized = {
+ name: 'index_template_without_mappings',
+ indexPatterns: ['indexPattern1'],
+ dataStream: {},
+ indexMode: 'standard',
+ template: {},
+ allowAutoCreate: 'NO_OVERWRITE',
+ ignoreMissingComponentTemplates: [],
+ composedOf: [],
+ _kbnMeta: {
+ type: 'default',
+ hasDatastream: true,
+ isLegacy: false,
+ },
+ };
+
+ const { _kbnMeta: _ignoredKbnMeta, template: _ignoredTemplate, ...logistics } = initialTemplate;
+
+ const result = buildTemplateFromWizardData({
+ initialTemplate,
+ wizardData: {
+ logistics,
+ mappings: {
+ properties: {
+ field_1: { type: 'text' },
+ },
+ },
+ settings: undefined,
+ aliases: undefined,
+ components: [],
+ },
+ });
+
+ expect(result.template?.mappings).toEqual({
+ properties: {
+ field_1: { type: 'text' },
+ },
+ });
+ });
+
+ test('keeps settings when added in the wizard', () => {
+ const initialTemplate: TemplateDeserialized = {
+ name: 'index_template_without_settings',
+ indexPatterns: ['indexPattern1'],
+ dataStream: {},
+ indexMode: 'standard',
+ template: {},
+ allowAutoCreate: 'NO_OVERWRITE',
+ ignoreMissingComponentTemplates: [],
+ composedOf: [],
+ _kbnMeta: {
+ type: 'default',
+ hasDatastream: true,
+ isLegacy: false,
+ },
+ };
+
+ const { _kbnMeta: _ignoredKbnMeta, template: _ignoredTemplate, ...logistics } = initialTemplate;
+
+ const result = buildTemplateFromWizardData({
+ initialTemplate,
+ wizardData: {
+ logistics,
+ settings: {
+ index: {
+ number_of_shards: 2,
+ },
+ },
+ mappings: undefined,
+ aliases: undefined,
+ components: [],
+ },
+ });
+
+ expect(result.template?.settings).toEqual({
+ index: {
+ number_of_shards: 2,
+ },
+ });
+ });
+
+ test('keeps aliases when added in the wizard', () => {
+ const initialTemplate: TemplateDeserialized = {
+ name: 'index_template_without_aliases',
+ indexPatterns: ['indexPattern1'],
+ dataStream: {},
+ indexMode: 'standard',
+ template: {},
+ allowAutoCreate: 'NO_OVERWRITE',
+ ignoreMissingComponentTemplates: [],
+ composedOf: [],
+ _kbnMeta: {
+ type: 'default',
+ hasDatastream: true,
+ isLegacy: false,
+ },
+ };
+
+ const { _kbnMeta: _ignoredKbnMeta, template: _ignoredTemplate, ...logistics } = initialTemplate;
+
+ const result = buildTemplateFromWizardData({
+ initialTemplate,
+ wizardData: {
+ logistics,
+ aliases: {
+ my_alias: {
+ is_write_index: true,
+ },
+ },
+ settings: undefined,
+ mappings: undefined,
+ components: [],
+ },
+ });
+
+ expect(result.template?.aliases).toEqual({
+ my_alias: {
+ is_write_index: true,
+ },
+ });
+ });
+
+ test('uses wizard component templates instead of initial template', () => {
+ const initialTemplate: TemplateDeserialized = {
+ name: 'template_with_components',
+ indexPatterns: ['indexPattern1'],
+ dataStream: {},
+ indexMode: 'standard',
+ template: {},
+ allowAutoCreate: 'NO_OVERWRITE',
+ ignoreMissingComponentTemplates: [],
+ composedOf: ['initial_component'],
+ _kbnMeta: {
+ type: 'default',
+ hasDatastream: true,
+ isLegacy: false,
+ },
+ };
+
+ const { _kbnMeta: _ignoredKbnMeta, template: _ignoredTemplate, ...logistics } = initialTemplate;
+
+ const result = buildTemplateFromWizardData({
+ initialTemplate,
+ wizardData: {
+ logistics,
+ settings: undefined,
+ mappings: undefined,
+ aliases: undefined,
+ components: ['wizard_component'],
+ },
+ });
+
+ expect(result.composedOf).toEqual(['wizard_component']);
+ });
+
+ test('preserves initial template-only fields', () => {
+ const initialTemplate: TemplateDeserialized = {
+ name: 'template_with_preserved_fields',
+ indexPatterns: ['indexPattern1'],
+ dataStream: {},
+ indexMode: 'standard',
+ template: {},
+ allowAutoCreate: 'NO_OVERWRITE',
+ ignoreMissingComponentTemplates: ['initial_missing_component'],
+ composedOf: [],
+ deprecated: true,
+ _kbnMeta: {
+ type: 'default',
+ hasDatastream: true,
+ isLegacy: false,
+ },
+ };
+
+ const { _kbnMeta: _ignoredKbnMeta, template: _ignoredTemplate, ...logistics } = initialTemplate;
+
+ const result = buildTemplateFromWizardData({
+ initialTemplate,
+ wizardData: {
+ logistics: {
+ ...logistics,
+ deprecated: false,
+ ignoreMissingComponentTemplates: [],
+ },
+ settings: undefined,
+ mappings: undefined,
+ aliases: undefined,
+ components: [],
+ },
+ });
+
+ expect(result._kbnMeta).toEqual(initialTemplate._kbnMeta);
+ 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/components/template_form/utils/build_template_from_wizard_data.ts b/x-pack/platform/plugins/shared/index_management/public/application/components/template_form/utils/build_template_from_wizard_data.ts
new file mode 100644
index 0000000000000..adca56e3f45d4
--- /dev/null
+++ b/x-pack/platform/plugins/shared/index_management/public/application/components/template_form/utils/build_template_from_wizard_data.ts
@@ -0,0 +1,77 @@
+/*
+ * 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 type { TemplateDeserialized } from '../../../../../common';
+import type { DataStreamOptions } from '../../../../../common/types/data_streams';
+import { serializeAsESLifecycle } from '../../../../../common/lib';
+
+export interface TemplateFormWizardData {
+ logistics: Omit;
+ settings?: Record;
+ mappings?: Record;
+ aliases?: Record;
+ components: TemplateDeserialized['composedOf'];
+}
+
+/**
+ * If no mappings, settings or aliases are defined, it is better to not send empty
+ * object for those values.
+ * This method takes care of that and other cleanup of empty fields.
+ * @param template The template object to clean up
+ */
+const cleanupTemplateObject = (template: TemplateDeserialized) => {
+ const outputTemplate = { ...template };
+
+ if (outputTemplate.template) {
+ if (outputTemplate.template.settings === undefined) {
+ delete outputTemplate.template.settings;
+ }
+ if (outputTemplate.template.mappings === undefined) {
+ delete outputTemplate.template.mappings;
+ }
+ if (outputTemplate.template.aliases === undefined) {
+ delete outputTemplate.template.aliases;
+ }
+ if (Object.keys(outputTemplate.template).length === 0) {
+ delete outputTemplate.template;
+ }
+ if (outputTemplate.lifecycle) {
+ delete outputTemplate.lifecycle;
+ }
+ }
+
+ return outputTemplate;
+};
+
+export const buildTemplateFromWizardData = ({
+ initialTemplate,
+ wizardData,
+ dataStreamOptions,
+}: {
+ initialTemplate: TemplateDeserialized;
+ wizardData: TemplateFormWizardData;
+ dataStreamOptions?: DataStreamOptions;
+}): TemplateDeserialized => {
+ const outputTemplate = {
+ ...wizardData.logistics,
+ _kbnMeta: initialTemplate._kbnMeta,
+ deprecated: initialTemplate.deprecated,
+ composedOf: wizardData.components,
+ template: {
+ settings: wizardData.settings,
+ mappings: wizardData.mappings,
+ aliases: wizardData.aliases,
+ lifecycle: wizardData.logistics.lifecycle
+ ? serializeAsESLifecycle(wizardData.logistics.lifecycle)
+ : undefined,
+ ...(dataStreamOptions && { data_stream_options: dataStreamOptions }),
+ },
+ ignoreMissingComponentTemplates: initialTemplate.ignoreMissingComponentTemplates,
+ };
+
+ return cleanupTemplateObject(outputTemplate as TemplateDeserialized);
+};
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
new file mode 100644
index 0000000000000..138382b0d7667
--- /dev/null
+++ b/x-pack/platform/plugins/shared/index_management/public/application/sections/template_edit/template_edit.test.tsx
@@ -0,0 +1,251 @@
+/*
+ * 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 { API_BASE_PATH } from '../../../../common/constants';
+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 { 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,
+ sendRequest: jest.fn(),
+ useRequest: jest.fn(),
+}));
+
+jest.mock('../../../shared_imports', () => ({
+ PageLoading: ({ children }: { children: React.ReactNode }) => (
+ {children}
+ ),
+ PageError: ({ 'data-test-subj': dataTestSubj }: { 'data-test-subj'?: string }) => (
+
+ ),
+ attemptToURIDecode: (value: string) => value,
+}));
+
+jest.mock('../../components', () => ({
+ __esModule: true,
+ TemplateForm: ({
+ defaultValue,
+ onSave,
+ isEditing,
+ title,
+ }: {
+ defaultValue: TemplateDeserialized;
+ onSave: (t: TemplateDeserialized) => void;
+ isEditing?: boolean;
+ title?: React.ReactNode;
+ }) => (
+
+
{title}
+
{String(Boolean(isEditing))}
+
onSave(defaultValue)}
+ />
+
+ ),
+}));
+
+const renderWithProviders = (ui: React.ReactElement) => {
+ return render({ui});
+};
+
+const createRouterProps = ({ name, search = '' }: { name: string; search?: string }) => {
+ const history = createMemoryHistory({
+ initialEntries: [`/edit_template/${encodeURIComponent(name)}${search}`],
+ });
+ const match = matchPath<{ name: string }>(history.location.pathname, {
+ path: '/edit_template/:name',
+ exact: true,
+ strict: false,
+ });
+
+ if (!match) {
+ throw new Error('Expected route to match /edit_template/:name');
+ }
+
+ return { history, location: history.location, match };
+};
+
+const makeTemplate = (overrides: Partial = {}): TemplateDeserialized => ({
+ name: 'index_template_without_mappings',
+ indexPatterns: ['indexPattern1'],
+ version: 1,
+ allowAutoCreate: 'NO_OVERWRITE',
+ indexMode: 'standard',
+ dataStream: {
+ hidden: true,
+ anyUnknownKey: 'should_be_kept',
+ },
+ template: {
+ lifecycle: {
+ enabled: true,
+ data_retention: '1d',
+ },
+ },
+ _kbnMeta: {
+ type: 'default',
+ hasDatastream: true,
+ 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('TemplateEdit', () => {
+ beforeEach(() => {
+ breadcrumbService.setup(jest.fn());
+ 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,
+ })
+ );
+ const { history, location, match } = createRouterProps({ name: 'my_template' });
+
+ renderWithProviders();
+
+ expect(screen.getByTestId('pageLoading')).toBeInTheDocument();
+ });
+
+ test('renders error state when load fails', () => {
+ jest.mocked(useRequest).mockReturnValue(
+ getUseRequestMock({
+ isLoading: false,
+ error: createRequestError('boom'),
+ data: null,
+ })
+ );
+ const { history, location, match } = createRouterProps({ name: 'my_template' });
+
+ renderWithProviders();
+
+ expect(screen.getByTestId('sectionError')).toBeInTheDocument();
+ });
+
+ test('blocks editing cloud managed templates', () => {
+ jest.mocked(useRequest).mockReturnValue(
+ getUseRequestMock({
+ isLoading: false,
+ error: null,
+ data: makeTemplate({
+ _kbnMeta: { type: 'cloudManaged', hasDatastream: true, isLegacy: false },
+ }),
+ })
+ );
+ const { history, location, match } = createRouterProps({ name: 'my_template' });
+
+ renderWithProviders();
+
+ expect(screen.getByTestId('systemTemplateEditCallout')).toBeInTheDocument();
+ });
+
+ test('shows system template warning callout', () => {
+ jest.mocked(useRequest).mockReturnValue(
+ getUseRequestMock({
+ isLoading: false,
+ error: null,
+ data: makeTemplate({ name: '.system_template' }),
+ })
+ );
+ const { history, location, match } = createRouterProps({ name: '.system_template' });
+
+ renderWithProviders();
+
+ expect(screen.getByTestId('systemTemplateEditCallout')).toBeInTheDocument();
+ });
+
+ test('shows deprecated template warning callout', () => {
+ jest.mocked(useRequest).mockReturnValue(
+ getUseRequestMock({
+ isLoading: false,
+ error: null,
+ data: makeTemplate({ deprecated: true }),
+ })
+ );
+ const { history, location, match } = createRouterProps({ name: 'my_template' });
+
+ renderWithProviders();
+
+ expect(screen.getByTestId('deprecatedIndexTemplateCallout')).toBeInTheDocument();
+ });
+
+ test('wires save to PUT /index_templates/{name} and navigates', async () => {
+ const template = makeTemplate();
+ jest
+ .mocked(useRequest)
+ .mockReturnValue(getUseRequestMock({ isLoading: false, error: null, data: template }));
+ const { history, location, match } = createRouterProps({ name: template.name });
+ const pushSpy = jest.spyOn(history, 'push');
+
+ renderWithProviders();
+
+ expect(screen.getByTestId('mockTemplateFormTitle')).toHaveTextContent(
+ `Edit template '${template.name}'`
+ );
+ expect(screen.getByTestId('mockIsEditing')).toHaveTextContent('true');
+
+ fireEvent.click(screen.getByTestId('mockTemplateFormSave'));
+
+ await waitFor(() =>
+ expect(sendRequest).toHaveBeenCalledWith({
+ path: `${API_BASE_PATH}/index_templates/${encodeURIComponent(template.name)}`,
+ method: 'put',
+ body: JSON.stringify(template),
+ })
+ );
+
+ await waitFor(() => {
+ expect(pushSpy).toHaveBeenCalledWith(getTemplateDetailsLink(template.name, false));
+ });
+ });
+});
diff --git a/x-pack/platform/plugins/shared/index_management/tsconfig.json b/x-pack/platform/plugins/shared/index_management/tsconfig.json
index ec90a0111f252..914f9a5add325 100644
--- a/x-pack/platform/plugins/shared/index_management/tsconfig.json
+++ b/x-pack/platform/plugins/shared/index_management/tsconfig.json
@@ -5,6 +5,7 @@
},
"include": [
"__jest__/**/*",
+ "__mocks__/**/*",
"common/**/*",
"public/**/*",
"server/**/*",