From 854f47c4e50d47b7bccd6fe9d4fdb12d18c0e03b Mon Sep 17 00:00:00 2001 From: Karen Grigoryan Date: Thu, 26 Mar 2026 21:42:27 +0100 Subject: [PATCH] [Index Management] Migrate flaky integration tests to unit tests (#258942) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Migrates flaky Index Management client integration coverage to focused unit/section tests (mappings editor + template wizard). - Speeds up `mappings_editor.test.tsx` by mocking UI-heavy bits and adds compensation unit tests for `TypeParameter` and `ReferenceFieldSelects`. - Removes legacy `__jest__/client_integration` specs/helpers for these areas. - Closes #257682 - Closes #256305 - Closes #253534 - Closes #239816 - Closes #239817 - Closes #239818 - Closes #254922 - Closes #254951 - [Integration → unit test mapping (gist)](https://gist.github.com/kapral18/24d218baaddaa374822493dd812cfe7e) - [x] `node scripts/check_changes.ts` - [x] `yarn test:jest x-pack/platform/plugins/shared/index_management/public/application/components/mappings_editor/mappings_editor.test.tsx x-pack/platform/plugins/shared/index_management/public/application/components/mappings_editor/mappings_editor.edit_field.test.tsx x-pack/platform/plugins/shared/index_management/public/application/components/mappings_editor/mappings_editor.text_datatype.test.tsx x-pack/platform/plugins/shared/index_management/public/application/components/mappings_editor/components/document_fields/field_parameters/select_inference_id.test.tsx x-pack/platform/plugins/shared/index_management/public/application/components/mappings_editor/components/document_fields/field_parameters/type_parameter.test.tsx x-pack/platform/plugins/shared/index_management/public/application/components/mappings_editor/components/document_fields/field_parameters/reference_field_selects.test.tsx` - [x] `yarn test:jest x-pack/platform/plugins/shared/index_management/public/application/sections/template_clone/template_clone.test.tsx x-pack/platform/plugins/shared/index_management/public/application/sections/template_create/template_create.test.tsx x-pack/platform/plugins/shared/index_management/public/application/sections/template_edit/template_edit.test.tsx x-pack/platform/plugins/shared/index_management/public/application/components/template_form/utils/build_template_from_wizard_data.test.ts x-pack/platform/plugins/shared/index_management/public/application/components/template_form/steps/step_components.test.tsx x-pack/platform/plugins/shared/index_management/public/application/components/template_form/steps/step_logistics.test.tsx x-pack/platform/plugins/shared/index_management/public/application/components/template_form/steps/step_review.test.tsx` --------- Co-authored-by: Claude Opus 4.6 (1M context) (cherry picked from commit a31fae822fc66741614cb3bf85053306b68e37dd) --- .../select_inference_id.test.tsx | 598 ---------- .../template_clone.test.tsx | 117 -- .../template_edit.test.tsx | 468 -------- .../__mocks__/@kbn/code-editor/index.tsx | 53 + .../plugins/shared/index_management/moon.yml | 1 + .../client_integration/datatypes/index.ts | 9 - .../datatypes/text_datatype.test.tsx | 427 ------- .../client_integration/edit_field.test.tsx | 151 --- .../reference_field_selects.test.tsx | 137 +++ .../select_inference_id.test.tsx | 519 +++++++++ .../field_parameters/type_parameter.test.tsx | 105 ++ .../mappings_editor.edit_field.test.tsx | 270 +++++ .../mappings_editor/mappings_editor.test.tsx | 1003 +++++++++++++++++ .../mappings_editor.text_datatype.test.tsx | 508 +++++++++ .../steps/step_components.test.tsx | 157 +++ .../steps/step_logistics.test.tsx | 64 ++ .../template_form/steps/step_review.test.tsx | 111 ++ .../template_form/template_form.test.tsx | 524 +++++++++ .../template_form/template_form.tsx | 55 +- .../build_template_from_wizard_data.test.ts | 421 +++++++ .../utils/build_template_from_wizard_data.ts | 77 ++ .../template_clone/template_clone.test.tsx | 214 ++++ .../template_create/template_create.test.tsx | 182 +++ .../template_edit/template_edit.test.tsx | 251 +++++ .../shared/index_management/tsconfig.json | 1 + 25 files changed, 4604 insertions(+), 1819 deletions(-) delete mode 100644 x-pack/platform/plugins/shared/index_management/__jest__/client_integration/index_details_page/select_inference_id.test.tsx delete mode 100644 x-pack/platform/plugins/shared/index_management/__jest__/client_integration/index_template_wizard/template_clone.test.tsx delete mode 100644 x-pack/platform/plugins/shared/index_management/__jest__/client_integration/index_template_wizard/template_edit.test.tsx create mode 100644 x-pack/platform/plugins/shared/index_management/__mocks__/@kbn/code-editor/index.tsx delete mode 100644 x-pack/platform/plugins/shared/index_management/public/application/components/mappings_editor/__jest__/client_integration/datatypes/index.ts delete mode 100644 x-pack/platform/plugins/shared/index_management/public/application/components/mappings_editor/__jest__/client_integration/datatypes/text_datatype.test.tsx delete mode 100644 x-pack/platform/plugins/shared/index_management/public/application/components/mappings_editor/__jest__/client_integration/edit_field.test.tsx create mode 100644 x-pack/platform/plugins/shared/index_management/public/application/components/mappings_editor/components/document_fields/field_parameters/reference_field_selects.test.tsx create mode 100644 x-pack/platform/plugins/shared/index_management/public/application/components/mappings_editor/components/document_fields/field_parameters/select_inference_id.test.tsx create mode 100644 x-pack/platform/plugins/shared/index_management/public/application/components/mappings_editor/components/document_fields/field_parameters/type_parameter.test.tsx create mode 100644 x-pack/platform/plugins/shared/index_management/public/application/components/mappings_editor/mappings_editor.edit_field.test.tsx create mode 100644 x-pack/platform/plugins/shared/index_management/public/application/components/mappings_editor/mappings_editor.test.tsx create mode 100644 x-pack/platform/plugins/shared/index_management/public/application/components/mappings_editor/mappings_editor.text_datatype.test.tsx create mode 100644 x-pack/platform/plugins/shared/index_management/public/application/components/template_form/steps/step_components.test.tsx create mode 100644 x-pack/platform/plugins/shared/index_management/public/application/components/template_form/steps/step_logistics.test.tsx create mode 100644 x-pack/platform/plugins/shared/index_management/public/application/components/template_form/steps/step_review.test.tsx create mode 100644 x-pack/platform/plugins/shared/index_management/public/application/components/template_form/template_form.test.tsx create mode 100644 x-pack/platform/plugins/shared/index_management/public/application/components/template_form/utils/build_template_from_wizard_data.test.ts create mode 100644 x-pack/platform/plugins/shared/index_management/public/application/components/template_form/utils/build_template_from_wizard_data.ts create mode 100644 x-pack/platform/plugins/shared/index_management/public/application/sections/template_clone/template_clone.test.tsx create mode 100644 x-pack/platform/plugins/shared/index_management/public/application/sections/template_create/template_create.test.tsx create mode 100644 x-pack/platform/plugins/shared/index_management/public/application/sections/template_edit/template_edit.test.tsx 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
{children}
; -} - -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 ( + +
{children}
+
+ ); +}; + +const mockUseMappingsState = jest.requireMock('../../../mappings_state_context') + .useMappingsState as jest.Mock; + +const ReferenceFieldValueSpy = () => { + const [{ reference_field: referenceField }] = useFormData<{ reference_field?: string }>({ + watch: 'reference_field', + }); + return
{referenceField ?? ''}
; +}; + +describe('ReferenceFieldSelects', () => { + describe('WHEN rendered with mixed field types in mappings state', () => { + it('SHOULD list only non-multi text fields', async () => { + mockUseMappingsState.mockReturnValue({ + mappingViewFields: { + byId: { + title: { source: { type: 'text' }, isMultiField: false, path: ['title'] }, + keyword: { source: { type: 'keyword' }, isMultiField: false, path: ['keyword'] }, + }, + }, + fields: { + byId: { + nestedText: { source: { type: 'text' }, isMultiField: false, path: ['a', 'b'] }, + multiText: { source: { type: 'text' }, isMultiField: true, path: ['multi'] }, + }, + }, + }); + + render( + + + + + ); + + const select = await screen.findByTestId('select'); + const optionValues = Array.from((select as HTMLSelectElement).options).map((o) => o.value); + + expect(optionValues).toContain('title'); + expect(optionValues).toContain('a.b'); + expect(optionValues).not.toContain('keyword'); + expect(optionValues).not.toContain('multi'); + }); + }); + + describe('WHEN an option is selected', () => { + it('SHOULD update the reference_field form value', async () => { + mockUseMappingsState.mockReturnValue({ + mappingViewFields: { + byId: { + title: { source: { type: 'text' }, isMultiField: false, path: ['title'] }, + }, + }, + fields: { byId: {} }, + }); + + render( + + + + + ); + + const select = (await screen.findByTestId('select')) as HTMLSelectElement; + await act(async () => { + fireEvent.change(select, { target: { value: 'title' } }); + fireEvent.blur(select); + }); + + expect(await screen.findByTestId('referenceFieldSpy')).toHaveTextContent('title'); + }); + }); +}); diff --git a/x-pack/platform/plugins/shared/index_management/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
{children}
; +} + +const renderSelectInferenceId = async ({ initialValue }: { initialValue?: string } = {}) => { + let result: ReturnType | undefined; + await act(async () => { + result = render( + + + + ); + }); + return result!; +}; + +describe('SelectInferenceId', () => { + let user: ReturnType; + + beforeEach(() => { + jest.useFakeTimers(); + jest.restoreAllMocks(); + jest.clearAllMocks(); + user = userEvent.setup({ + advanceTimers: jest.advanceTimersByTime, + pointerEventsCheck: 0, + delay: null, + }); + }); + + afterEach(() => { + act(() => { + jest.runOnlyPendingTimers(); + }); + jest.useRealTimers(); + }); + + describe('WHEN component is rendered', () => { + beforeEach(() => { + setupInferenceEndpointsMocks(); + }); + + it('SHOULD display the component with button', async () => { + 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 ( + +
{children}
+
+ ); +}; + +describe('TypeParameter', () => { + describe('WHEN isSemanticTextEnabled is false', () => { + it('SHOULD filter out the semantic_text option', async () => { + render( + + + + ); + + const optionsText = await screen.findByTestId('mockComboBoxOptions'); + expect(optionsText.textContent).not.toContain('semantic_text'); + }); + }); + + describe('WHEN isRootLevelField is false', () => { + it('SHOULD filter out root-only types', async () => { + render( + + + + ); + + const optionsText = await screen.findByTestId('mockComboBoxOptions'); + for (const rootOnlyType of TYPE_ONLY_ALLOWED_AT_ROOT_LEVEL) { + expect(optionsText.textContent).not.toContain(rootOnlyType); + } + }); + }); + + describe('WHEN showDocLink is true and a type is selected', () => { + it('SHOULD render the documentation link', async () => { + render( + + + + ); + + const link = await screen.findByRole('link'); + expect(link).toHaveAttribute('href', '/docs/text'); + expect(link.textContent).toContain('documentation'); + }); + }); +}); diff --git a/x-pack/platform/plugins/shared/index_management/public/application/components/mappings_editor/mappings_editor.edit_field.test.tsx b/x-pack/platform/plugins/shared/index_management/public/application/components/mappings_editor/mappings_editor.edit_field.test.tsx new file mode 100644 index 0000000000000..0e3998d501db3 --- /dev/null +++ b/x-pack/platform/plugins/shared/index_management/public/application/components/mappings_editor/mappings_editor.edit_field.test.tsx @@ -0,0 +1,270 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import type { ComponentProps } from 'react'; +import { render, screen, within, waitFor, fireEvent } from '@testing-library/react'; +import { I18nProvider } from '@kbn/i18n-react'; +import SemVer from 'semver/classes/semver'; +import { docLinksServiceMock, uiSettingsServiceMock } from '@kbn/core/public/mocks'; +import { GlobalFlyout } from '@kbn/es-ui-shared-plugin/public'; + +import { MAJOR_VERSION } from '../../../../common'; +import { useAppContext } from '../../app_context'; +import { MappingsEditor } from './mappings_editor'; +import { MappingsEditorProvider } from './mappings_editor_context'; +import { createKibanaReactContext } from './shared_imports'; + +type UseFieldType = typeof import('./shared_imports').UseField; +type GetFieldConfigType = typeof import('./lib').getFieldConfig; + +jest.mock('@kbn/code-editor'); + +jest.mock('@elastic/eui', () => { + const actual = jest.requireActual('@elastic/eui'); + + return { + ...actual, + EuiPortal: ({ children }: { children?: React.ReactNode }) => <>{children}, + EuiOverlayMask: ({ children }: { children?: React.ReactNode }) => <>{children}, + }; +}); + +jest.mock('./components/document_fields/field_parameters/type_parameter', () => { + const sharedImports = jest.requireActual('./shared_imports'); + const lib = jest.requireActual('./lib'); + const UseFieldActual = sharedImports.UseField as UseFieldType; + const getFieldConfigActual = lib.getFieldConfig as GetFieldConfigType; + + const options = [ + { value: 'text', label: 'text' }, + { value: 'range', label: 'range' }, + { value: 'date_range', label: 'date_range' }, + { value: 'other', label: 'other' }, + ]; + + const TypeParameter = () => ( + > + path="type" + config={getFieldConfigActual>('type')} + > + {(field) => { + return ( + + ); + }} + + ); + + return { __esModule: true, TypeParameter }; +}); + +jest.mock('../../app_context', () => { + const actual = jest.requireActual('../../app_context'); + return { + ...actual, + useAppContext: jest.fn(), + }; +}); + +const { GlobalFlyoutProvider } = GlobalFlyout; +const mockUseAppContext = useAppContext as unknown as jest.MockedFunction; +const docLinks = docLinksServiceMock.createStartContract(); +const kibanaVersion = new SemVer(MAJOR_VERSION); +const { Provider: KibanaReactContextProvider } = createKibanaReactContext({ + uiSettings: uiSettingsServiceMock.createSetupContract(), + kibanaVersion: { + get: () => kibanaVersion, + }, +}); + +const defaultTextParameters = { + type: 'text', + eager_global_ordinals: false, + fielddata: false, + index: true, + index_options: 'positions', + index_phrases: false, + norms: true, + store: false, +}; + +const defaultDateRangeParameters = { + type: 'date_range', + coerce: true, + index: true, + store: false, +}; + +describe('Mappings editor: edit field', () => { + const onChangeHandler = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + mockUseAppContext.mockReturnValue({ + canUseSyntheticSource: true, + config: { + enableMappingsSourceFieldSection: false, + }, + } as unknown as ReturnType); + }); + + const getFieldListItemById = (id: string) => screen.getByTestId(`fieldsListItem ${id}Field`); + + type MappingsEditorProps = ComponentProps; + + const setup = (props: Partial) => { + return render( + + + + + + + + + + + + ); + }; + + describe('WHEN a nested field edit button is clicked', () => { + it('SHOULD open a flyout with the correct field to edit', async () => { + const defaultMappings = { + properties: { + user: { + type: 'object', + properties: { + street: { type: 'text' }, + }, + }, + }, + }; + + setup({ value: defaultMappings, onChange: onChangeHandler, indexSettings: {} }); + + const userField = await screen.findByTestId('fieldsListItem userField'); + fireEvent.click(within(userField).getByTestId('toggleExpandButton')); + + const streetListItem = await screen.findByTestId('fieldsListItem userstreetField'); + + fireEvent.click(within(streetListItem).getByTestId('editFieldButton')); + + const flyout = await screen.findByTestId('mappingsEditorFieldEdit'); + + const flyoutTitle = within(flyout).getByTestId('flyoutTitle'); + expect(flyoutTitle.textContent).toEqual(`Edit field 'street'`); + + const fieldPath = within(flyout).getByTestId('fieldPath'); + expect(fieldPath.textContent).toEqual('user > street'); + }); + }); + + describe('WHEN the field datatype is changed', () => { + it('SHOULD update form parameters accordingly', async () => { + const defaultMappings = { + properties: { + userName: { + ...defaultTextParameters, + }, + }, + }; + + setup({ value: defaultMappings, onChange: onChangeHandler, indexSettings: {} }); + + await screen.findByTestId('mappingsEditor'); + await screen.findByTestId('fieldsList'); + + const userNameListItem = getFieldListItemById('userName'); + expect(userNameListItem).toBeInTheDocument(); + + fireEvent.click(within(userNameListItem).getByTestId('editFieldButton')); + + const flyout = await screen.findByTestId('mappingsEditorFieldEdit'); + + const fieldTypeSelect = within(flyout).getByTestId('fieldType'); + fireEvent.change(fieldTypeSelect, { target: { value: 'range' } }); + fireEvent.blur(fieldTypeSelect); + + await within(flyout).findByTestId('fieldSubType'); + + const updateButton = within(flyout).getByTestId('editFieldUpdateButton'); + await waitFor(() => { + expect(updateButton).not.toBeDisabled(); + }); + fireEvent.click(updateButton); + + await waitFor(() => { + expect(screen.queryByTestId('mappingsEditorFieldEdit')).not.toBeInTheDocument(); + }); + + await waitFor(() => { + const lastCall = onChangeHandler.mock.calls[onChangeHandler.mock.calls.length - 1][0]; + const data = lastCall.getData(lastCall.isValid ?? true); + + const updatedMappings = { + ...defaultMappings, + properties: { + userName: { + ...defaultDateRangeParameters, + }, + }, + }; + + expect(data).toEqual(updatedMappings); + }); + }); + }); + + describe('WHEN the field editor is opened without changes', () => { + it('SHOULD have the Update button disabled until changes are made', async () => { + const defaultMappings = { + properties: { + myField: { + type: 'text', + }, + }, + }; + + setup({ value: defaultMappings, onChange: onChangeHandler, indexSettings: {} }); + + await screen.findByTestId('mappingsEditor'); + await screen.findByTestId('fieldsList'); + + const myFieldListItem = getFieldListItemById('myField'); + + fireEvent.click(within(myFieldListItem).getByTestId('editFieldButton')); + + const flyout = await screen.findByTestId('mappingsEditorFieldEdit'); + + const updateButton = within(flyout).getByTestId('editFieldUpdateButton'); + expect(updateButton).toBeDisabled(); + + fireEvent.change(within(flyout).getByTestId('nameParameterInput'), { + target: { value: 'updatedField' }, + }); + expect(updateButton).not.toBeDisabled(); + }); + }); +}); diff --git a/x-pack/platform/plugins/shared/index_management/public/application/components/mappings_editor/mappings_editor.test.tsx b/x-pack/platform/plugins/shared/index_management/public/application/components/mappings_editor/mappings_editor.test.tsx 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}
+
+ ); + }, +})); + +jest.mock('../../../shared_imports', () => ({ + PageLoading: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), + PageError: ({ 'data-test-subj': dataTestSubj }: { 'data-test-subj'?: string }) => ( +
+ ), + attemptToURIDecode: (value: string) => value, +})); + +const renderWithProviders = (ui: React.ReactElement) => render({ui}); + +const createRouterProps = ({ name, search = '' }: { name: string; search?: string }) => { + const history = createMemoryHistory({ + initialEntries: [`/clone_template/${encodeURIComponent(name)}${search}`], + }); + const match = matchPath<{ name: string }>(history.location.pathname, { + path: '/clone_template/:name', + exact: true, + strict: false, + }); + + if (!match) { + throw new Error('Expected route to match /clone_template/:name'); + } + + return { history, location: history.location, match }; +}; + +const makeTemplate = (overrides: Partial = {}): TemplateDeserialized => ({ + name: 'my_template', + indexPatterns: ['indexPattern1'], + version: 1, + allowAutoCreate: 'NO_OVERWRITE', + indexMode: 'standard', + dataStream: {}, + template: {}, + ignoreMissingComponentTemplates: [], + composedOf: [], + _kbnMeta: { + type: 'default', + hasDatastream: false, + isLegacy: false, + }, + ...overrides, +}); + +const createRequestError = (message: string): EsUiSharedError => ({ error: message, message }); + +const getUseRequestMock = ({ + isInitialRequest = false, + isLoading, + error, + data, +}: { + isInitialRequest?: boolean; + isLoading: boolean; + error: EsUiSharedError | null; + data: T | null; +}): UseRequestResponse => ({ + isInitialRequest, + isLoading, + error, + data, + resendRequest: jest.fn(), +}); + +describe('TemplateClone', () => { + beforeEach(() => { + breadcrumbService.setup(jest.fn()); + jest.restoreAllMocks(); + jest.clearAllMocks(); + mockTemplateFormPropsSpy.mockClear(); + const okResponse: Awaited> = { data: null, error: null }; + jest.mocked(saveTemplate).mockResolvedValue(okResponse); + }); + + describe('WHEN the template is loading', () => { + it('SHOULD render the loading state', () => { + jest + .mocked(useLoadIndexTemplate) + .mockReturnValue( + getUseRequestMock({ isLoading: true, error: null, data: null }) + ); + + const { history, location, match } = createRouterProps({ name: 'my_template' }); + renderWithProviders(); + + expect(screen.getByTestId('pageLoading')).toBeInTheDocument(); + }); + }); + + describe('WHEN the template load fails', () => { + it('SHOULD render the error state', () => { + jest.mocked(useLoadIndexTemplate).mockReturnValue( + getUseRequestMock({ + isLoading: false, + error: createRequestError('boom'), + data: null, + }) + ); + + const { history, location, match } = createRouterProps({ name: 'my_template' }); + renderWithProviders(); + + expect(screen.getByTestId('sectionError')).toBeInTheDocument(); + }); + }); + + describe('WHEN the template is loaded successfully', () => { + it('SHOULD set the cloned default name and wire save with clone flag', async () => { + const template = makeTemplate({ + name: 'my_template', + indexPatterns: ['index-1', 'index-2'], + version: 7, + allowAutoCreate: 'TRUE', + indexMode: 'standard', + template: { settings: { index: { number_of_shards: 1 } } }, + }); + jest + .mocked(useLoadIndexTemplate) + .mockReturnValue(getUseRequestMock({ isLoading: false, error: null, data: template })); + + const { history, location, match } = createRouterProps({ name: template.name }); + const pushSpy = jest.spyOn(history, 'push'); + + renderWithProviders(); + + expect(await screen.findByTestId('mockTemplateFormDefaultName')).toHaveTextContent( + `${template.name}-copy` + ); + expect(screen.getByTestId('mockTemplateFormTitle')).toHaveTextContent( + `Clone template '${template.name}'` + ); + + fireEvent.click(screen.getByTestId('mockTemplateFormSave')); + + await waitFor(() => { + expect(saveTemplate).toHaveBeenCalledTimes(1); + }); + + const [savedTemplate, cloneFlag] = jest.mocked(saveTemplate).mock.calls[0]; + + expect(savedTemplate).toEqual({ + ...template, + name: `${template.name}-copy`, + }); + expect(cloneFlag).toBe(true); + + await waitFor(() => { + expect(pushSpy).toHaveBeenCalledWith(getTemplateDetailsLink(savedTemplate.name, false)); + }); + }); + }); +}); diff --git a/x-pack/platform/plugins/shared/index_management/public/application/sections/template_create/template_create.test.tsx b/x-pack/platform/plugins/shared/index_management/public/application/sections/template_create/template_create.test.tsx new file mode 100644 index 0000000000000..463274deea37a --- /dev/null +++ b/x-pack/platform/plugins/shared/index_management/public/application/sections/template_create/template_create.test.tsx @@ -0,0 +1,182 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { I18nProvider } from '@kbn/i18n-react'; +import { createMemoryHistory } from 'history'; +import { matchPath } from 'react-router-dom'; + +import type { TemplateDeserialized } from '../../../../common'; +import { breadcrumbService } from '../../services/breadcrumbs'; +import { getTemplateDetailsLink } from '../../services/routing'; +import { saveTemplate } from '../../services/api'; +import { TemplateCreate } from './template_create'; + +jest.mock('../../services/api', () => ({ + ...jest.requireActual('../../services/api'), + saveTemplate: jest.fn(), +})); + +const mockUseAppContext = jest.fn(); +jest.mock('../../app_context', () => ({ + ...jest.requireActual('../../app_context'), + useAppContext: () => mockUseAppContext(), +})); + +const mockUseLocation = jest.fn(); +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useLocation: () => mockUseLocation(), +})); + +let mockTemplateToSave: TemplateDeserialized | null = null; +const mockTemplateFormPropsSpy = jest.fn(); + +interface TemplateFormMockProps { + defaultValue?: TemplateDeserialized; + onSave: (t: TemplateDeserialized) => void; + title: React.ReactNode; + isLegacy?: boolean; +} + +jest.mock('../../components', () => ({ + __esModule: true, + TemplateForm: (props: TemplateFormMockProps) => { + mockTemplateFormPropsSpy(props); + const { defaultValue, onSave, title } = props; + return ( +
+
{title}
+
{String(props.isLegacy)}
+
+ ); + }, +})); + +const renderWithProviders = (ui: React.ReactElement) => render({ui}); + +describe('TemplateCreate', () => { + beforeEach(() => { + breadcrumbService.setup(jest.fn()); + jest.restoreAllMocks(); + jest.clearAllMocks(); + mockTemplateFormPropsSpy.mockClear(); + mockUseLocation.mockReturnValue({ search: '' }); + mockTemplateToSave = null; + mockUseAppContext.mockReturnValue({ config: { enableLegacyTemplates: true } }); + const okResponse: Awaited> = { data: null, error: null }; + jest.mocked(saveTemplate).mockResolvedValue(okResponse); + }); + + describe('WHEN legacy query param is set and legacy templates are enabled', () => { + it('SHOULD pass isLegacy=true to TemplateForm', () => { + mockUseLocation.mockReturnValue({ search: '?legacy=true' }); + const history = createMemoryHistory({ initialEntries: ['/create_template'] }); + const match = matchPath(history.location.pathname, { + path: '/create_template', + exact: true, + strict: false, + }); + if (!match) { + throw new Error('Expected route to match /create_template'); + } + + renderWithProviders( + + ); + + expect(screen.getByTestId('mockTemplateFormIsLegacy')).toHaveTextContent('true'); + expect(screen.getByTestId('mockTemplateFormTitle')).toHaveTextContent( + 'Create legacy template' + ); + }); + }); + + describe('WHEN legacy query param is set but legacy templates are disabled', () => { + it('SHOULD force isLegacy=false', () => { + mockUseLocation.mockReturnValue({ search: '?legacy=true' }); + mockUseAppContext.mockReturnValue({ config: { enableLegacyTemplates: false } }); + const history = createMemoryHistory({ initialEntries: ['/create_template'] }); + const match = matchPath(history.location.pathname, { + path: '/create_template', + exact: true, + strict: false, + }); + if (!match) { + throw new Error('Expected route to match /create_template'); + } + + renderWithProviders( + + ); + + expect(screen.getByTestId('mockTemplateFormIsLegacy')).toHaveTextContent('false'); + expect(screen.getByTestId('mockTemplateFormTitle')).toHaveTextContent('Create template'); + }); + }); + + describe('WHEN the form is saved', () => { + it('SHOULD call saveTemplate and navigate to the template details', async () => { + const history = createMemoryHistory({ initialEntries: ['/create_template'] }); + const match = matchPath(history.location.pathname, { + path: '/create_template', + exact: true, + strict: false, + }); + if (!match) { + throw new Error('Expected route to match /create_template'); + } + const pushSpy = jest.spyOn(history, 'push'); + + const template: TemplateDeserialized = { + name: 'new_template', + indexPatterns: ['index-*'], + dataStream: {}, + indexMode: 'standard', + template: {}, + allowAutoCreate: 'NO_OVERWRITE', + ignoreMissingComponentTemplates: [], + composedOf: [], + _kbnMeta: { type: 'default', hasDatastream: false, isLegacy: false }, + }; + + mockTemplateToSave = template; + + renderWithProviders( + + ); + + fireEvent.click(screen.getByTestId('mockTemplateFormSave')); + + await waitFor(() => { + expect(saveTemplate).toHaveBeenCalledWith(template); + expect(pushSpy).toHaveBeenCalledWith(getTemplateDetailsLink(template.name, false)); + }); + }); + }); +}); diff --git a/x-pack/platform/plugins/shared/index_management/public/application/sections/template_edit/template_edit.test.tsx b/x-pack/platform/plugins/shared/index_management/public/application/sections/template_edit/template_edit.test.tsx 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))}
+
+ ), +})); + +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/**/*",