diff --git a/x-pack/plugins/index_management/common/lib/component_template_serialization.test.ts b/x-pack/plugins/index_management/common/lib/component_template_serialization.test.ts
index eaa7f24017a2..83682f45918e 100644
--- a/x-pack/plugins/index_management/common/lib/component_template_serialization.test.ts
+++ b/x-pack/plugins/index_management/common/lib/component_template_serialization.test.ts
@@ -4,91 +4,164 @@
* you may not use this file except in compliance with the Elastic License.
*/
-import { deserializeComponentTemplate } from './component_template_serialization';
+import {
+ deserializeComponentTemplate,
+ serializeComponentTemplate,
+} from './component_template_serialization';
-describe('deserializeComponentTemplate', () => {
- test('deserializes a component template', () => {
- expect(
- deserializeComponentTemplate(
- {
- name: 'my_component_template',
- component_template: {
- version: 1,
- _meta: {
- serialization: {
- id: 10,
- class: 'MyComponentTemplate',
- },
- description: 'set number of shards to one',
- },
- template: {
- settings: {
- number_of_shards: 1,
- },
- mappings: {
- _source: {
- enabled: false,
- },
- properties: {
- host_name: {
- type: 'keyword',
- },
- created_at: {
- type: 'date',
- format: 'EEE MMM dd HH:mm:ss Z yyyy',
- },
- },
- },
- },
- },
- },
- [
+describe('Component template serialization', () => {
+ describe('deserializeComponentTemplate()', () => {
+ test('deserializes a component template', () => {
+ expect(
+ deserializeComponentTemplate(
{
- name: 'my_index_template',
- index_template: {
- index_patterns: ['foo'],
+ name: 'my_component_template',
+ component_template: {
+ version: 1,
+ _meta: {
+ serialization: {
+ id: 10,
+ class: 'MyComponentTemplate',
+ },
+ description: 'set number of shards to one',
+ },
template: {
settings: {
- number_of_replicas: 2,
+ number_of_shards: 1,
+ },
+ mappings: {
+ _source: {
+ enabled: false,
+ },
+ properties: {
+ host_name: {
+ type: 'keyword',
+ },
+ created_at: {
+ type: 'date',
+ format: 'EEE MMM dd HH:mm:ss Z yyyy',
+ },
+ },
},
},
- composed_of: ['my_component_template'],
},
},
- ]
- )
- ).toEqual({
- name: 'my_component_template',
- version: 1,
- _meta: {
- serialization: {
- id: 10,
- class: 'MyComponentTemplate',
- },
- description: 'set number of shards to one',
- },
- template: {
- settings: {
- number_of_shards: 1,
- },
- mappings: {
- _source: {
- enabled: false,
- },
- properties: {
- host_name: {
- type: 'keyword',
+ [
+ {
+ name: 'my_index_template',
+ index_template: {
+ index_patterns: ['foo'],
+ template: {
+ settings: {
+ number_of_replicas: 2,
+ },
+ },
+ composed_of: ['my_component_template'],
+ },
},
- created_at: {
- type: 'date',
- format: 'EEE MMM dd HH:mm:ss Z yyyy',
+ ]
+ )
+ ).toEqual({
+ name: 'my_component_template',
+ version: 1,
+ _meta: {
+ serialization: {
+ id: 10,
+ class: 'MyComponentTemplate',
+ },
+ description: 'set number of shards to one',
+ },
+ template: {
+ settings: {
+ number_of_shards: 1,
+ },
+ mappings: {
+ _source: {
+ enabled: false,
+ },
+ properties: {
+ host_name: {
+ type: 'keyword',
+ },
+ created_at: {
+ type: 'date',
+ format: 'EEE MMM dd HH:mm:ss Z yyyy',
+ },
},
},
},
- },
- _kbnMeta: {
- usedBy: ['my_index_template'],
- },
+ _kbnMeta: {
+ usedBy: ['my_index_template'],
+ },
+ });
+ });
+ });
+
+ describe('serializeComponentTemplate()', () => {
+ test('serialize a component template', () => {
+ expect(
+ serializeComponentTemplate({
+ name: 'my_component_template',
+ version: 1,
+ _kbnMeta: {
+ usedBy: [],
+ },
+ _meta: {
+ serialization: {
+ id: 10,
+ class: 'MyComponentTemplate',
+ },
+ description: 'set number of shards to one',
+ },
+ template: {
+ settings: {
+ number_of_shards: 1,
+ },
+ mappings: {
+ _source: {
+ enabled: false,
+ },
+ properties: {
+ host_name: {
+ type: 'keyword',
+ },
+ created_at: {
+ type: 'date',
+ format: 'EEE MMM dd HH:mm:ss Z yyyy',
+ },
+ },
+ },
+ },
+ })
+ ).toEqual({
+ version: 1,
+ _meta: {
+ serialization: {
+ id: 10,
+ class: 'MyComponentTemplate',
+ },
+ description: 'set number of shards to one',
+ },
+ template: {
+ settings: {
+ number_of_shards: 1,
+ },
+ mappings: {
+ _source: {
+ enabled: false,
+ },
+ properties: {
+ host_name: {
+ type: 'keyword',
+ },
+ created_at: {
+ type: 'date',
+ format: 'EEE MMM dd HH:mm:ss Z yyyy',
+ },
+ },
+ },
+ },
+ });
});
});
});
diff --git a/x-pack/plugins/index_management/common/lib/component_template_serialization.ts b/x-pack/plugins/index_management/common/lib/component_template_serialization.ts
index 0db81bf81d30..672b8140f79f 100644
--- a/x-pack/plugins/index_management/common/lib/component_template_serialization.ts
+++ b/x-pack/plugins/index_management/common/lib/component_template_serialization.ts
@@ -8,6 +8,7 @@ import {
ComponentTemplateFromEs,
ComponentTemplateDeserialized,
ComponentTemplateListItem,
+ ComponentTemplateSerialized,
} from '../types';
const hasEntries = (data: object = {}) => Object.entries(data).length > 0;
@@ -84,3 +85,15 @@ export function deserializeComponenTemplateList(
return componentTemplateListItem;
}
+
+export function serializeComponentTemplate(
+ componentTemplateDeserialized: ComponentTemplateDeserialized
+): ComponentTemplateSerialized {
+ const { version, template, _meta } = componentTemplateDeserialized;
+
+ return {
+ version,
+ template,
+ _meta,
+ };
+}
diff --git a/x-pack/plugins/index_management/common/lib/index.ts b/x-pack/plugins/index_management/common/lib/index.ts
index 6b1005b4faa0..f39cc063ba73 100644
--- a/x-pack/plugins/index_management/common/lib/index.ts
+++ b/x-pack/plugins/index_management/common/lib/index.ts
@@ -20,4 +20,5 @@ export { getTemplateParameter } from './utils';
export {
deserializeComponentTemplate,
deserializeComponenTemplateList,
+ serializeComponentTemplate,
} from './component_template_serialization';
diff --git a/x-pack/plugins/index_management/public/application/app.tsx b/x-pack/plugins/index_management/public/application/app.tsx
index 92197bee30c8..8d78995a94e2 100644
--- a/x-pack/plugins/index_management/public/application/app.tsx
+++ b/x-pack/plugins/index_management/public/application/app.tsx
@@ -16,6 +16,11 @@ import { TemplateClone } from './sections/template_clone';
import { TemplateEdit } from './sections/template_edit';
import { useServices } from './app_context';
+import {
+ ComponentTemplateCreate,
+ ComponentTemplateEdit,
+ ComponentTemplateClone,
+} from './components';
export const App = ({ history }: { history: ScopedHistory }) => {
const { uiMetricService } = useServices();
@@ -34,6 +39,13 @@ export const AppWithoutRouter = () => (
+
+
+
diff --git a/x-pack/plugins/index_management/public/application/app_context.tsx b/x-pack/plugins/index_management/public/application/app_context.tsx
index c82190712037..6fbe177d24e0 100644
--- a/x-pack/plugins/index_management/public/application/app_context.tsx
+++ b/x-pack/plugins/index_management/public/application/app_context.tsx
@@ -6,9 +6,10 @@
import React, { createContext, useContext } from 'react';
import { ScopedHistory } from 'kibana/public';
+import { ManagementAppMountParams } from 'src/plugins/management/public';
import { UsageCollectionSetup } from 'src/plugins/usage_collection/public';
-
import { CoreStart } from '../../../../../src/core/public';
+
import { IngestManagerSetup } from '../../../ingest_manager/public';
import { IndexMgmtMetricsType } from '../types';
import { UiMetricService, NotificationService, HttpService } from './services';
@@ -32,6 +33,7 @@ export interface AppDependencies {
notificationService: NotificationService;
};
history: ScopedHistory;
+ setBreadcrumbs: ManagementAppMountParams['setBreadcrumbs'];
}
export const AppContextProvider = ({
diff --git a/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/component_template_create.test.tsx b/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/component_template_create.test.tsx
new file mode 100644
index 000000000000..6c8da4684f01
--- /dev/null
+++ b/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/component_template_create.test.tsx
@@ -0,0 +1,218 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import React from 'react';
+import { act } from 'react-dom/test-utils';
+
+import { setupEnvironment } from './helpers';
+import { setup, ComponentTemplateCreateTestBed } from './helpers/component_template_create.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']]);
+ }}
+ />
+ ),
+ // Mocking EuiCodeEditor, which uses React Ace under the hood
+ EuiCodeEditor: (props: any) => (
+ {
+ props.onChange(syntheticEvent.jsonString);
+ }}
+ />
+ ),
+ };
+});
+
+describe('', () => {
+ let testBed: ComponentTemplateCreateTestBed;
+
+ const { server, httpRequestsMockHelpers } = setupEnvironment();
+
+ afterAll(() => {
+ server.restore();
+ });
+
+ describe('On component mount', () => {
+ beforeEach(async () => {
+ await act(async () => {
+ testBed = await setup();
+ });
+
+ testBed.component.update();
+ });
+
+ test('should set the correct page header', async () => {
+ const { exists, find } = testBed;
+
+ // Verify page title
+ expect(exists('pageTitle')).toBe(true);
+ expect(find('pageTitle').text()).toEqual('Create component template');
+
+ // Verify documentation link
+ expect(exists('documentationLink')).toBe(true);
+ expect(find('documentationLink').text()).toBe('Component Templates docs');
+ });
+
+ describe('Step: Logistics', () => {
+ test('should toggle the metadata field', async () => {
+ const { exists, component, actions } = testBed;
+
+ // Meta editor should be hidden by default
+ // Since the editor itself is mocked, we checked for the mocked element
+ expect(exists('mockCodeEditor')).toBe(false);
+
+ await act(async () => {
+ actions.toggleMetaSwitch();
+ });
+
+ component.update();
+
+ expect(exists('mockCodeEditor')).toBe(true);
+ });
+
+ describe('Validation', () => {
+ test('should require a name', async () => {
+ const { form, actions, component, find } = testBed;
+
+ await act(async () => {
+ // Submit logistics step without any values
+ actions.clickNextButton();
+ });
+
+ component.update();
+
+ // Verify name is required
+ expect(form.getErrorsMessages()).toEqual(['A component template name is required.']);
+ expect(find('nextButton').props().disabled).toEqual(true);
+ });
+ });
+ });
+
+ describe('Step: Review and submit', () => {
+ const COMPONENT_TEMPLATE_NAME = 'comp-1';
+ const SETTINGS = { number_of_shards: 1 };
+ const ALIASES = { my_alias: {} };
+
+ const BOOLEAN_MAPPING_FIELD = {
+ name: 'boolean_datatype',
+ type: 'boolean',
+ };
+
+ beforeEach(async () => {
+ await act(async () => {
+ testBed = await setup();
+ });
+
+ const { actions, component } = testBed;
+
+ component.update();
+
+ // Complete step 1 (logistics)
+ await actions.completeStepLogistics({ name: COMPONENT_TEMPLATE_NAME });
+
+ // Complete step 2 (index settings)
+ await actions.completeStepSettings(SETTINGS);
+
+ // Complete step 3 (mappings)
+ await actions.completeStepMappings([BOOLEAN_MAPPING_FIELD]);
+
+ // Complete step 4 (aliases)
+ await actions.completeStepAliases(ALIASES);
+ });
+
+ test('should render the review content', () => {
+ const { find, exists, actions } = testBed;
+ // Verify page header
+ expect(exists('stepReview')).toBe(true);
+ expect(find('stepReview.title').text()).toEqual(
+ `Review details for '${COMPONENT_TEMPLATE_NAME}'`
+ );
+
+ // Verify 2 tabs exist
+ expect(find('stepReview.content').find('.euiTab').length).toBe(2);
+ expect(
+ find('stepReview.content')
+ .find('.euiTab')
+ .map((t) => t.text())
+ ).toEqual(['Summary', 'Request']);
+
+ // Summary tab should render by default
+ expect(exists('stepReview.summaryTab')).toBe(true);
+ expect(exists('stepReview.requestTab')).toBe(false);
+
+ // Navigate to request tab and verify content
+ actions.selectReviewTab('request');
+
+ expect(exists('stepReview.summaryTab')).toBe(false);
+ expect(exists('stepReview.requestTab')).toBe(true);
+ });
+
+ test('should send the correct payload when submitting the form', async () => {
+ const { actions, component } = testBed;
+
+ await act(async () => {
+ actions.clickNextButton();
+ });
+
+ component.update();
+
+ const latestRequest = server.requests[server.requests.length - 1];
+
+ const expected = {
+ name: COMPONENT_TEMPLATE_NAME,
+ template: {
+ settings: SETTINGS,
+ mappings: {
+ _source: {},
+ _meta: {},
+ properties: {
+ [BOOLEAN_MAPPING_FIELD.name]: {
+ type: BOOLEAN_MAPPING_FIELD.type,
+ },
+ },
+ },
+ aliases: ALIASES,
+ },
+ _kbnMeta: { usedBy: [] },
+ };
+
+ expect(JSON.parse(JSON.parse(latestRequest.requestBody).body)).toEqual(expected);
+ });
+
+ test('should surface API errors if the request is unsuccessful', async () => {
+ const { component, actions, find, exists } = testBed;
+
+ const error = {
+ status: 409,
+ error: 'Conflict',
+ message: `There is already a template with name '${COMPONENT_TEMPLATE_NAME}'`,
+ };
+
+ httpRequestsMockHelpers.setCreateComponentTemplateResponse(undefined, { body: error });
+
+ await act(async () => {
+ actions.clickNextButton();
+ });
+
+ component.update();
+
+ expect(exists('saveComponentTemplateError')).toBe(true);
+ expect(find('saveComponentTemplateError').text()).toContain(error.message);
+ });
+ });
+ });
+});
diff --git a/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/component_template_edit.test.tsx b/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/component_template_edit.test.tsx
new file mode 100644
index 000000000000..f237605756d5
--- /dev/null
+++ b/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/component_template_edit.test.tsx
@@ -0,0 +1,123 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import React from 'react';
+import { act } from 'react-dom/test-utils';
+
+import { setupEnvironment } from './helpers';
+import { setup, ComponentTemplateEditTestBed } from './helpers/component_template_edit.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']]);
+ }}
+ />
+ ),
+ // Mocking EuiCodeEditor, which uses React Ace under the hood
+ EuiCodeEditor: (props: any) => (
+ {
+ props.onChange(syntheticEvent.jsonString);
+ }}
+ />
+ ),
+ };
+});
+
+describe('', () => {
+ let testBed: ComponentTemplateEditTestBed;
+
+ const { server, httpRequestsMockHelpers } = setupEnvironment();
+
+ afterAll(() => {
+ server.restore();
+ });
+
+ const COMPONENT_TEMPLATE_NAME = 'comp-1';
+ const COMPONENT_TEMPLATE_TO_EDIT = {
+ name: COMPONENT_TEMPLATE_NAME,
+ template: {
+ settings: { number_of_shards: 1 },
+ },
+ _kbnMeta: { usedBy: [] },
+ };
+
+ beforeEach(async () => {
+ httpRequestsMockHelpers.setLoadComponentTemplateResponse(COMPONENT_TEMPLATE_TO_EDIT);
+
+ await act(async () => {
+ testBed = await setup();
+ });
+
+ testBed.component.update();
+ });
+
+ test('should set the correct page title', () => {
+ const { exists, find } = testBed;
+
+ expect(exists('pageTitle')).toBe(true);
+ expect(find('pageTitle').text()).toEqual(
+ `Edit component template '${COMPONENT_TEMPLATE_NAME}'`
+ );
+ });
+
+ it('should set the name field to read only', () => {
+ const { find } = testBed;
+
+ const nameInput = find('nameField.input');
+ expect(nameInput.props().disabled).toEqual(true);
+ });
+
+ describe('form payload', () => {
+ it('should send the correct payload with changed values', async () => {
+ const { actions, component, form } = testBed;
+
+ await act(async () => {
+ form.setInputValue('versionField.input', '1');
+ actions.clickNextButton();
+ });
+
+ component.update();
+
+ await actions.completeStepSettings();
+ await actions.completeStepMappings();
+ await actions.completeStepAliases();
+
+ await act(async () => {
+ actions.clickNextButton();
+ });
+
+ component.update();
+
+ const latestRequest = server.requests[server.requests.length - 1];
+
+ const expected = {
+ version: 1,
+ ...COMPONENT_TEMPLATE_TO_EDIT,
+ template: {
+ ...COMPONENT_TEMPLATE_TO_EDIT.template,
+ mappings: {
+ _meta: {},
+ _source: {},
+ properties: {},
+ },
+ },
+ };
+
+ expect(JSON.parse(JSON.parse(latestRequest.requestBody).body)).toEqual(expected);
+ });
+ });
+});
diff --git a/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/helpers/component_template_create.helpers.ts b/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/helpers/component_template_create.helpers.ts
new file mode 100644
index 000000000000..e6ced2fcc309
--- /dev/null
+++ b/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/helpers/component_template_create.helpers.ts
@@ -0,0 +1,38 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { registerTestBed, TestBed, TestBedConfig } from '../../../../../../../../../test_utils';
+import { BASE_PATH } from '../../../../../../../common';
+import { ComponentTemplateCreate } from '../../../component_template_wizard';
+
+import { WithAppDependencies } from './setup_environment';
+import {
+ getFormActions,
+ ComponentTemplateFormTestSubjects,
+} from './component_template_form.helpers';
+
+export type ComponentTemplateCreateTestBed = TestBed & {
+ actions: ReturnType;
+};
+
+const testBedConfig: TestBedConfig = {
+ memoryRouter: {
+ initialEntries: [`${BASE_PATH}/create_component_template`],
+ componentRoutePath: `${BASE_PATH}/create_component_template`,
+ },
+ doMountAsync: true,
+};
+
+const initTestBed = registerTestBed(WithAppDependencies(ComponentTemplateCreate), testBedConfig);
+
+export const setup = async (): Promise => {
+ const testBed = await initTestBed();
+
+ return {
+ ...testBed,
+ actions: getFormActions(testBed),
+ };
+};
diff --git a/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/helpers/component_template_edit.helpers.ts b/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/helpers/component_template_edit.helpers.ts
new file mode 100644
index 000000000000..3c0cbb19577a
--- /dev/null
+++ b/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/helpers/component_template_edit.helpers.ts
@@ -0,0 +1,38 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { registerTestBed, TestBed, TestBedConfig } from '../../../../../../../../../test_utils';
+import { BASE_PATH } from '../../../../../../../common';
+import { ComponentTemplateEdit } from '../../../component_template_wizard';
+
+import { WithAppDependencies } from './setup_environment';
+import {
+ getFormActions,
+ ComponentTemplateFormTestSubjects,
+} from './component_template_form.helpers';
+
+export type ComponentTemplateEditTestBed = TestBed & {
+ actions: ReturnType;
+};
+
+const testBedConfig: TestBedConfig = {
+ memoryRouter: {
+ initialEntries: [`${BASE_PATH}/edit_component_template/comp-1`],
+ componentRoutePath: `${BASE_PATH}/edit_component_template/:name`,
+ },
+ doMountAsync: true,
+};
+
+const initTestBed = registerTestBed(WithAppDependencies(ComponentTemplateEdit), testBedConfig);
+
+export const setup = async (): Promise => {
+ const testBed = await initTestBed();
+
+ return {
+ ...testBed,
+ actions: getFormActions(testBed),
+ };
+};
diff --git a/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/helpers/component_template_form.helpers.ts b/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/helpers/component_template_form.helpers.ts
new file mode 100644
index 000000000000..f92f46d71e7c
--- /dev/null
+++ b/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/helpers/component_template_form.helpers.ts
@@ -0,0 +1,159 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+import { act } from 'react-dom/test-utils';
+
+import { TestBed } from '../../../../../../../../../test_utils';
+
+interface MappingField {
+ name: string;
+ type: string;
+}
+
+export const getFormActions = (testBed: TestBed) => {
+ // User actions
+ const toggleVersionSwitch = () => {
+ testBed.form.toggleEuiSwitch('versionToggle');
+ };
+
+ const toggleMetaSwitch = () => {
+ testBed.form.toggleEuiSwitch('metaToggle');
+ };
+
+ const clickNextButton = () => {
+ testBed.find('nextButton').simulate('click');
+ };
+
+ const clickBackButton = () => {
+ testBed.find('backButton').simulate('click');
+ };
+
+ const clickSubmitButton = () => {
+ testBed.find('submitButton').simulate('click');
+ };
+
+ const setMetaField = (jsonString: string) => {
+ testBed.find('mockCodeEditor').simulate('change', {
+ jsonString,
+ });
+ };
+
+ const selectReviewTab = (tab: 'summary' | 'request') => {
+ const tabs = ['summary', 'request'];
+
+ testBed.find('stepReview.content').find('.euiTab').at(tabs.indexOf(tab)).simulate('click');
+ };
+
+ const completeStepLogistics = async ({ name }: { name: string }) => {
+ const { form, component } = testBed;
+ // Add name field
+ form.setInputValue('nameField.input', name);
+
+ await act(async () => {
+ clickNextButton();
+ });
+
+ component.update();
+ };
+
+ const completeStepSettings = async (settings?: { [key: string]: any }) => {
+ const { find, component } = testBed;
+
+ await act(async () => {
+ if (settings) {
+ find('mockCodeEditor').simulate('change', {
+ jsonString: JSON.stringify(settings),
+ }); // Using mocked EuiCodeEditor
+ }
+
+ clickNextButton();
+ });
+
+ component.update();
+ };
+
+ const addMappingField = async (name: string, type: string) => {
+ const { find, form, component } = testBed;
+
+ await act(async () => {
+ form.setInputValue('nameParameterInput', name);
+ find('createFieldForm.mockComboBox').simulate('change', [
+ {
+ label: type,
+ value: type,
+ },
+ ]);
+ find('createFieldForm.addButton').simulate('click');
+ });
+
+ component.update();
+ };
+
+ const completeStepMappings = async (mappingFields?: MappingField[]) => {
+ const { component } = testBed;
+
+ if (mappingFields) {
+ for (const field of mappingFields) {
+ const { name, type } = field;
+ await addMappingField(name, type);
+ }
+ }
+
+ await act(async () => {
+ clickNextButton();
+ });
+
+ component.update();
+ };
+
+ const completeStepAliases = async (aliases?: { [key: string]: any }) => {
+ const { find, component } = testBed;
+
+ await act(async () => {
+ if (aliases) {
+ find('mockCodeEditor').simulate('change', {
+ jsonString: JSON.stringify(aliases),
+ }); // Using mocked EuiCodeEditor
+ }
+
+ clickNextButton();
+ });
+
+ component.update();
+ };
+
+ return {
+ toggleVersionSwitch,
+ toggleMetaSwitch,
+ clickNextButton,
+ clickBackButton,
+ clickSubmitButton,
+ setMetaField,
+ selectReviewTab,
+ completeStepSettings,
+ completeStepAliases,
+ completeStepLogistics,
+ completeStepMappings,
+ };
+};
+
+export type ComponentTemplateFormTestSubjects =
+ | 'backButton'
+ | 'documentationLink'
+ | 'metaToggle'
+ | 'metaEditor'
+ | 'mockCodeEditor'
+ | 'nameField.input'
+ | 'nextButton'
+ | 'pageTitle'
+ | 'saveComponentTemplateError'
+ | 'submitButton'
+ | 'stepReview'
+ | 'stepReview.title'
+ | 'stepReview.content'
+ | 'stepReview.summaryTab'
+ | 'stepReview.requestTab'
+ | 'versionField'
+ | 'versionField.input';
diff --git a/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/helpers/http_requests.ts b/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/helpers/http_requests.ts
index b7b674292dd9..a4e532ba5d3d 100644
--- a/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/helpers/http_requests.ts
+++ b/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/helpers/http_requests.ts
@@ -5,7 +5,11 @@
*/
import sinon, { SinonFakeServer } from 'sinon';
-import { ComponentTemplateListItem, ComponentTemplateDeserialized } from '../../../shared_imports';
+import {
+ ComponentTemplateListItem,
+ ComponentTemplateDeserialized,
+ ComponentTemplateSerialized,
+} from '../../../shared_imports';
import { API_BASE_PATH } from './constants';
// Register helpers to mock HTTP Requests
@@ -46,10 +50,25 @@ const registerHttpRequestMockHelpers = (server: SinonFakeServer) => {
]);
};
+ const setCreateComponentTemplateResponse = (
+ response?: ComponentTemplateSerialized,
+ error?: any
+ ) => {
+ const status = error ? error.body.status || 400 : 200;
+ const body = error ? JSON.stringify(error.body) : JSON.stringify(response);
+
+ server.respondWith('POST', `${API_BASE_PATH}/component_templates`, [
+ status,
+ { 'Content-Type': 'application/json' },
+ body,
+ ]);
+ };
+
return {
setLoadComponentTemplatesResponse,
setDeleteComponentTemplateResponse,
setLoadComponentTemplateResponse,
+ setCreateComponentTemplateResponse,
};
};
diff --git a/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/helpers/setup_environment.tsx b/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/helpers/setup_environment.tsx
index a2194bbfa018..70634a226c67 100644
--- a/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/helpers/setup_environment.tsx
+++ b/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/helpers/setup_environment.tsx
@@ -27,6 +27,7 @@ const appDependencies = {
trackMetric: () => {},
docLinks: docLinksServiceMock.createStartContract(),
toasts: notificationServiceMock.createSetupContract().toasts,
+ setBreadcrumbs: () => {},
};
export const setupEnvironment = () => {
diff --git a/x-pack/plugins/index_management/public/application/components/component_templates/component_template_details/component_template_details.tsx b/x-pack/plugins/index_management/public/application/components/component_templates/component_template_details/component_template_details.tsx
index a8007c636358..f94c5c38f23d 100644
--- a/x-pack/plugins/index_management/public/application/components/component_templates/component_template_details/component_template_details.tsx
+++ b/x-pack/plugins/index_management/public/application/components/component_templates/component_template_details/component_template_details.tsx
@@ -24,6 +24,7 @@ import { useComponentTemplatesContext } from '../component_templates_context';
import { TabSummary } from './tab_summary';
import { ComponentTemplateTabs, TabType } from './tabs';
import { ManageButton, ManageAction } from './manage_button';
+import { attemptToDecodeURI } from '../lib';
interface Props {
componentTemplateName: string;
@@ -39,8 +40,10 @@ export const ComponentTemplateDetailsFlyout: React.FunctionComponent = ({
}) => {
const { api } = useComponentTemplatesContext();
+ const decodedComponentTemplateName = attemptToDecodeURI(componentTemplateName);
+
const { data: componentTemplateDetails, isLoading, error } = api.useLoadComponentTemplate(
- componentTemplateName
+ decodedComponentTemplateName
);
const [activeTab, setActiveTab] = useState('summary');
@@ -108,7 +111,7 @@ export const ComponentTemplateDetailsFlyout: React.FunctionComponent = ({
- {componentTemplateName}
+ {decodedComponentTemplateName}
diff --git a/x-pack/plugins/index_management/public/application/components/component_templates/component_template_details/tab_summary.tsx b/x-pack/plugins/index_management/public/application/components/component_templates/component_template_details/tab_summary.tsx
index 401186f6c962..80f28f23c9f9 100644
--- a/x-pack/plugins/index_management/public/application/components/component_templates/component_template_details/tab_summary.tsx
+++ b/x-pack/plugins/index_management/public/application/components/component_templates/component_template_details/tab_summary.tsx
@@ -74,7 +74,7 @@ export const TabSummary: React.FunctionComponent = ({ componentTemplateDe
)}
{/* Version (optional) */}
- {version && (
+ {typeof version !== 'undefined' && (
<>
= ({
const [componentTemplatesToDelete, setComponentTemplatesToDelete] = useState([]);
- const goToList = () => {
- return history.push('component_templates');
+ const goToComponentTemplateList = () => {
+ return history.push({
+ pathname: 'component_templates',
+ });
+ };
+
+ const goToEditComponentTemplate = (name: string) => {
+ return history.push({
+ pathname: encodeURI(`edit_component_template/${encodeURIComponent(name)}`),
+ });
+ };
+
+ const goToCloneComponentTemplate = (name: string) => {
+ return history.push({
+ pathname: encodeURI(`create_component_template/${encodeURIComponent(name)}`),
+ });
};
// Track component loaded
@@ -60,11 +75,13 @@ export const ComponentTemplateList: React.FunctionComponent = ({
componentTemplates={data}
onReloadClick={sendRequest}
onDeleteClick={setComponentTemplatesToDelete}
+ onEditClick={goToEditComponentTemplate}
+ onCloneClick={goToCloneComponentTemplate}
history={history as ScopedHistory}
/>
);
} else if (data && data.length === 0) {
- content = ;
+ content = ;
} else if (error) {
content = ;
}
@@ -81,7 +98,7 @@ export const ComponentTemplateList: React.FunctionComponent = ({
// refetch the component templates
sendRequest();
// go back to list view (if deleted from details flyout)
- goToList();
+ goToComponentTemplateList();
}
setComponentTemplatesToDelete([]);
}}
@@ -92,9 +109,25 @@ export const ComponentTemplateList: React.FunctionComponent = ({
{/* details flyout */}
{componentTemplateName && (
+ goToEditComponentTemplate(attemptToDecodeURI(componentTemplateName)),
+ },
+ {
+ name: i18n.translate('xpack.idxMgmt.componentTemplateDetails.cloneActionLabel', {
+ defaultMessage: 'Clone',
+ }),
+ icon: 'copy',
+ handleActionClick: () =>
+ goToCloneComponentTemplate(attemptToDecodeURI(componentTemplateName)),
+ },
{
name: i18n.translate('xpack.idxMgmt.componentTemplateDetails.deleteButtonLabel', {
defaultMessage: 'Delete',
@@ -104,7 +137,7 @@ export const ComponentTemplateList: React.FunctionComponent = ({
details._kbnMeta.usedBy.length > 0,
closePopoverOnClick: true,
handleActionClick: () => {
- setComponentTemplatesToDelete([componentTemplateName]);
+ setComponentTemplatesToDelete([attemptToDecodeURI(componentTemplateName)]);
},
},
]}
diff --git a/x-pack/plugins/index_management/public/application/components/component_templates/component_template_list/empty_prompt.tsx b/x-pack/plugins/index_management/public/application/components/component_templates/component_template_list/empty_prompt.tsx
index edd9f77cbf63..fbb1968491ff 100644
--- a/x-pack/plugins/index_management/public/application/components/component_templates/component_template_list/empty_prompt.tsx
+++ b/x-pack/plugins/index_management/public/application/components/component_templates/component_template_list/empty_prompt.tsx
@@ -6,11 +6,17 @@
import React, { FunctionComponent } from 'react';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n/react';
-import { EuiEmptyPrompt, EuiLink } from '@elastic/eui';
+import { RouteComponentProps } from 'react-router-dom';
+import { EuiEmptyPrompt, EuiLink, EuiButton } from '@elastic/eui';
+import { reactRouterNavigate } from '../../../../../../../../src/plugins/kibana_react/public';
import { useComponentTemplatesContext } from '../component_templates_context';
-export const EmptyPrompt: FunctionComponent = () => {
+interface Props {
+ history: RouteComponentProps['history'];
+}
+
+export const EmptyPrompt: FunctionComponent = ({ history }) => {
const { documentation } = useComponentTemplatesContext();
return (
@@ -38,6 +44,17 @@ export const EmptyPrompt: FunctionComponent = () => {
}
+ actions={
+
+ {i18n.translate('xpack.idxMgmt.home.componentTemplates.emptyPromptButtonLabel', {
+ defaultMessage: 'Create a component template',
+ })}
+
+ }
/>
);
};
diff --git a/x-pack/plugins/index_management/public/application/components/component_templates/component_template_list/table.tsx b/x-pack/plugins/index_management/public/application/components/component_templates/component_template_list/table.tsx
index b67a249ae697..089c2f889e72 100644
--- a/x-pack/plugins/index_management/public/application/components/component_templates/component_template_list/table.tsx
+++ b/x-pack/plugins/index_management/public/application/components/component_templates/component_template_list/table.tsx
@@ -25,6 +25,8 @@ export interface Props {
componentTemplates: ComponentTemplateListItem[];
onReloadClick: () => void;
onDeleteClick: (componentTemplateName: string[]) => void;
+ onEditClick: (componentTemplateName: string) => void;
+ onCloneClick: (componentTemplateName: string) => void;
history: ScopedHistory;
}
@@ -32,6 +34,8 @@ export const ComponentTable: FunctionComponent = ({
componentTemplates,
onReloadClick,
onDeleteClick,
+ onEditClick,
+ onCloneClick,
history,
}) => {
const { trackMetric } = useComponentTemplatesContext();
@@ -85,6 +89,17 @@ export const ComponentTable: FunctionComponent = ({
defaultMessage: 'Reload',
})}
,
+
+ {i18n.translate('xpack.idxMgmt.componentTemplatesList.table.createButtonLabel', {
+ defaultMessage: 'Create a component template',
+ })}
+ ,
],
box: {
incremental: true,
@@ -135,7 +150,7 @@ export const ComponentTable: FunctionComponent = ({
{...reactRouterNavigate(
history,
{
- pathname: `/component_templates/${name}`,
+ pathname: encodeURI(`/component_templates/${encodeURIComponent(name)}`),
},
() => trackMetric('click', UIM_COMPONENT_TEMPLATE_DETAILS)
)}
@@ -204,8 +219,37 @@ export const ComponentTable: FunctionComponent = ({
),
actions: [
{
- 'data-test-subj': 'deleteComponentTemplateButton',
+ name: i18n.translate('xpack.idxMgmt.componentTemplatesList.table.actionEditText', {
+ defaultMessage: 'Edit',
+ }),
+ description: i18n.translate(
+ 'xpack.idxMgmt.componentTemplatesList.table.actionEditDecription',
+ {
+ defaultMessage: 'Edit this component template',
+ }
+ ),
+ onClick: ({ name }: ComponentTemplateListItem) => onEditClick(name),
isPrimary: true,
+ icon: 'pencil',
+ type: 'icon',
+ 'data-test-subj': 'editComponentTemplateButton',
+ },
+ {
+ name: i18n.translate('xpack.idxMgmt.componentTemplatesList.table.actionCloneText', {
+ defaultMessage: 'Clone',
+ }),
+ description: i18n.translate(
+ 'xpack.idxMgmt.componentTemplatesList.table.actionCloneDecription',
+ {
+ defaultMessage: 'Clone this component template',
+ }
+ ),
+ onClick: ({ name }: ComponentTemplateListItem) => onCloneClick(name),
+ icon: 'copy',
+ type: 'icon',
+ 'data-test-subj': 'cloneComponentTemplateButton',
+ },
+ {
name: i18n.translate('xpack.idxMgmt.componentTemplatesList.table.deleteActionLabel', {
defaultMessage: 'Delete',
}),
@@ -213,11 +257,13 @@ export const ComponentTable: FunctionComponent = ({
'xpack.idxMgmt.componentTemplatesList.table.deleteActionDescription',
{ defaultMessage: 'Delete this component template' }
),
+ onClick: ({ name }) => onDeleteClick([name]),
+ enabled: ({ usedBy }) => usedBy.length === 0,
+ isPrimary: true,
type: 'icon',
icon: 'trash',
color: 'danger',
- onClick: ({ name }) => onDeleteClick([name]),
- enabled: ({ usedBy }) => usedBy.length === 0,
+ 'data-test-subj': 'deleteComponentTemplateButton',
},
],
},
diff --git a/x-pack/plugins/index_management/public/application/components/component_templates/component_template_wizard/component_template_clone/component_template_clone.tsx b/x-pack/plugins/index_management/public/application/components/component_templates/component_template_wizard/component_template_clone/component_template_clone.tsx
new file mode 100644
index 000000000000..94db623f313c
--- /dev/null
+++ b/x-pack/plugins/index_management/public/application/components/component_templates/component_template_wizard/component_template_clone/component_template_clone.tsx
@@ -0,0 +1,61 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import React, { FunctionComponent, useEffect } from 'react';
+import { RouteComponentProps } from 'react-router-dom';
+import { i18n } from '@kbn/i18n';
+import { FormattedMessage } from '@kbn/i18n/react';
+
+import { SectionLoading } from '../../shared_imports';
+import { useComponentTemplatesContext } from '../../component_templates_context';
+import { attemptToDecodeURI } from '../../lib';
+import { ComponentTemplateCreate } from '../component_template_create';
+
+export interface Params {
+ sourceComponentTemplateName: string;
+}
+
+export const ComponentTemplateClone: FunctionComponent> = (props) => {
+ const { sourceComponentTemplateName } = props.match.params;
+ const decodedSourceName = attemptToDecodeURI(sourceComponentTemplateName);
+
+ const { toasts, api } = useComponentTemplatesContext();
+
+ const { error, data: componentTemplateToClone, isLoading } = api.useLoadComponentTemplate(
+ decodedSourceName
+ );
+
+ useEffect(() => {
+ if (error && !isLoading) {
+ toasts.addError(error, {
+ title: i18n.translate('xpack.idxMgmt.componentTemplateClone.loadComponentTemplateTitle', {
+ defaultMessage: `Error loading component template '{sourceComponentTemplateName}'.`,
+ values: { sourceComponentTemplateName },
+ }),
+ });
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [error, isLoading]);
+
+ if (isLoading) {
+ return (
+
+
+
+ );
+ } else {
+ // We still show the create form (unpopulated) even if we were not able to load the
+ // selected component template data.
+ const sourceComponentTemplate = componentTemplateToClone
+ ? { ...componentTemplateToClone, name: `${componentTemplateToClone.name}-copy` }
+ : undefined;
+
+ return ;
+ }
+};
diff --git a/x-pack/plugins/index_management/public/application/components/component_templates/component_template_wizard/component_template_clone/index.ts b/x-pack/plugins/index_management/public/application/components/component_templates/component_template_wizard/component_template_clone/index.ts
new file mode 100644
index 000000000000..b7165919644f
--- /dev/null
+++ b/x-pack/plugins/index_management/public/application/components/component_templates/component_template_wizard/component_template_clone/index.ts
@@ -0,0 +1,7 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+export { ComponentTemplateClone } from './component_template_clone';
diff --git a/x-pack/plugins/index_management/public/application/components/component_templates/component_template_wizard/component_template_create/component_template_create.tsx b/x-pack/plugins/index_management/public/application/components/component_templates/component_template_wizard/component_template_create/component_template_create.tsx
new file mode 100644
index 000000000000..94afadaed37f
--- /dev/null
+++ b/x-pack/plugins/index_management/public/application/components/component_templates/component_template_wizard/component_template_create/component_template_create.tsx
@@ -0,0 +1,83 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+import React, { useState, useEffect } from 'react';
+import { RouteComponentProps } from 'react-router-dom';
+import { FormattedMessage } from '@kbn/i18n/react';
+import { EuiPageBody, EuiPageContent, EuiSpacer, EuiTitle } from '@elastic/eui';
+
+import { ComponentTemplateDeserialized } from '../../shared_imports';
+import { useComponentTemplatesContext } from '../../component_templates_context';
+import { ComponentTemplateForm } from '../component_template_form';
+
+interface Props {
+ /**
+ * This value may be passed in to prepopulate the creation form (e.g., to clone a template)
+ */
+ sourceComponentTemplate?: any;
+}
+
+export const ComponentTemplateCreate: React.FunctionComponent = ({
+ history,
+ sourceComponentTemplate,
+}) => {
+ const [isSaving, setIsSaving] = useState(false);
+ const [saveError, setSaveError] = useState(null);
+
+ const { api, breadcrumbs } = useComponentTemplatesContext();
+
+ const onSave = async (componentTemplate: ComponentTemplateDeserialized) => {
+ const { name } = componentTemplate;
+
+ setIsSaving(true);
+ setSaveError(null);
+
+ const { error } = await api.createComponentTemplate(componentTemplate);
+
+ setIsSaving(false);
+
+ if (error) {
+ setSaveError(error);
+ return;
+ }
+
+ history.push({
+ pathname: encodeURI(`/component_templates/${encodeURIComponent(name)}`),
+ });
+ };
+
+ const clearSaveError = () => {
+ setSaveError(null);
+ };
+
+ useEffect(() => {
+ breadcrumbs.setCreateBreadcrumbs();
+ }, [breadcrumbs]);
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+};
diff --git a/x-pack/plugins/index_management/public/application/components/component_templates/component_template_wizard/component_template_create/index.ts b/x-pack/plugins/index_management/public/application/components/component_templates/component_template_wizard/component_template_create/index.ts
new file mode 100644
index 000000000000..6b0e02317888
--- /dev/null
+++ b/x-pack/plugins/index_management/public/application/components/component_templates/component_template_wizard/component_template_create/index.ts
@@ -0,0 +1,7 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+export { ComponentTemplateCreate } from './component_template_create';
diff --git a/x-pack/plugins/index_management/public/application/components/component_templates/component_template_wizard/component_template_edit/component_template_edit.tsx b/x-pack/plugins/index_management/public/application/components/component_templates/component_template_wizard/component_template_edit/component_template_edit.tsx
new file mode 100644
index 000000000000..2bd3dfb34acb
--- /dev/null
+++ b/x-pack/plugins/index_management/public/application/components/component_templates/component_template_wizard/component_template_edit/component_template_edit.tsx
@@ -0,0 +1,121 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+import React, { useState, useEffect } from 'react';
+import { RouteComponentProps } from 'react-router-dom';
+import { FormattedMessage } from '@kbn/i18n/react';
+import { EuiPageBody, EuiPageContent, EuiTitle, EuiSpacer, EuiCallOut } from '@elastic/eui';
+
+import { useComponentTemplatesContext } from '../../component_templates_context';
+import { ComponentTemplateDeserialized, SectionLoading } from '../../shared_imports';
+import { attemptToDecodeURI } from '../../lib';
+import { ComponentTemplateForm } from '../component_template_form';
+
+interface MatchParams {
+ name: string;
+}
+
+export const ComponentTemplateEdit: React.FunctionComponent> = ({
+ match: {
+ params: { name },
+ },
+ history,
+}) => {
+ const { api, breadcrumbs } = useComponentTemplatesContext();
+
+ const [isSaving, setIsSaving] = useState(false);
+ const [saveError, setSaveError] = useState(null);
+
+ const decodedName = attemptToDecodeURI(name);
+
+ const { error, data: componentTemplate, isLoading } = api.useLoadComponentTemplate(decodedName);
+
+ useEffect(() => {
+ breadcrumbs.setEditBreadcrumbs();
+ }, [breadcrumbs]);
+
+ const onSave = async (updatedComponentTemplate: ComponentTemplateDeserialized) => {
+ setIsSaving(true);
+ setSaveError(null);
+
+ const { error: saveErrorObject } = await api.updateComponentTemplate(updatedComponentTemplate);
+
+ setIsSaving(false);
+
+ if (saveErrorObject) {
+ setSaveError(saveErrorObject);
+ return;
+ }
+
+ history.push({
+ pathname: encodeURI(`/component_templates/${encodeURIComponent(name)}`),
+ });
+ };
+
+ const clearSaveError = () => {
+ setSaveError(null);
+ };
+
+ let content;
+
+ if (isLoading) {
+ content = (
+
+
+
+ );
+ } else if (error) {
+ content = (
+ <>
+
+ }
+ color="danger"
+ iconType="alert"
+ data-test-subj="loadComponentTemplateError"
+ >
+ {error.message}
+
+
+ >
+ );
+ } else if (componentTemplate) {
+ content = (
+
+ );
+ }
+
+ return (
+
+
+
+
+
+
+
+
+ {content}
+
+
+ );
+};
diff --git a/x-pack/plugins/index_management/public/application/components/component_templates/component_template_wizard/component_template_edit/index.ts b/x-pack/plugins/index_management/public/application/components/component_templates/component_template_wizard/component_template_edit/index.ts
new file mode 100644
index 000000000000..1f877bdae24f
--- /dev/null
+++ b/x-pack/plugins/index_management/public/application/components/component_templates/component_template_wizard/component_template_edit/index.ts
@@ -0,0 +1,7 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+export { ComponentTemplateEdit } from './component_template_edit';
diff --git a/x-pack/plugins/index_management/public/application/components/component_templates/component_template_wizard/component_template_form/component_template_form.tsx b/x-pack/plugins/index_management/public/application/components/component_templates/component_template_wizard/component_template_form/component_template_form.tsx
new file mode 100644
index 000000000000..6e35fbad31d4
--- /dev/null
+++ b/x-pack/plugins/index_management/public/application/components/component_templates/component_template_wizard/component_template_form/component_template_form.tsx
@@ -0,0 +1,209 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+import React, { useCallback } from 'react';
+import { i18n } from '@kbn/i18n';
+import { FormattedMessage } from '@kbn/i18n/react';
+import { EuiSpacer, EuiCallOut } from '@elastic/eui';
+
+import {
+ serializers,
+ Forms,
+ ComponentTemplateDeserialized,
+ CommonWizardSteps,
+ StepSettingsContainer,
+ StepMappingsContainer,
+ StepAliasesContainer,
+} from '../../shared_imports';
+import { useComponentTemplatesContext } from '../../component_templates_context';
+import { StepLogisticsContainer, StepReviewContainer } from './steps';
+
+const { stripEmptyFields } = serializers;
+const { FormWizard, FormWizardStep } = Forms;
+
+interface Props {
+ onSave: (componentTemplate: ComponentTemplateDeserialized) => void;
+ clearSaveError: () => void;
+ isSaving: boolean;
+ saveError: any;
+ defaultValue?: ComponentTemplateDeserialized;
+ isEditing?: boolean;
+}
+
+export interface WizardContent extends CommonWizardSteps {
+ logistics: Omit;
+}
+
+export type WizardSection = keyof WizardContent | 'review';
+
+const wizardSections: { [id: string]: { id: WizardSection; label: string } } = {
+ logistics: {
+ id: 'logistics',
+ label: i18n.translate('xpack.idxMgmt.componentTemplateForm.steps.logisticsStepName', {
+ defaultMessage: 'Logistics',
+ }),
+ },
+ settings: {
+ id: 'settings',
+ label: i18n.translate('xpack.idxMgmt.componentTemplateForm.steps.settingsStepName', {
+ defaultMessage: 'Index settings',
+ }),
+ },
+ mappings: {
+ id: 'mappings',
+ label: i18n.translate('xpack.idxMgmt.componentTemplateForm.steps.mappingsStepName', {
+ defaultMessage: 'Mappings',
+ }),
+ },
+ aliases: {
+ id: 'aliases',
+ label: i18n.translate('xpack.idxMgmt.componentTemplateForm.steps.aliasesStepName', {
+ defaultMessage: 'Aliases',
+ }),
+ },
+ review: {
+ id: 'review',
+ label: i18n.translate('xpack.idxMgmt.componentTemplateForm.steps.summaryStepName', {
+ defaultMessage: 'Review',
+ }),
+ },
+};
+
+export const ComponentTemplateForm = ({
+ defaultValue = {
+ name: '',
+ template: {
+ settings: {},
+ mappings: {},
+ aliases: {},
+ },
+ _meta: {},
+ _kbnMeta: {
+ usedBy: [],
+ },
+ },
+ isEditing,
+ isSaving,
+ saveError,
+ clearSaveError,
+ onSave,
+}: Props) => {
+ const {
+ template: { settings, mappings, aliases },
+ ...logistics
+ } = defaultValue;
+
+ const { documentation } = useComponentTemplatesContext();
+
+ const wizardDefaultValue: WizardContent = {
+ logistics,
+ settings,
+ mappings,
+ aliases,
+ };
+
+ const i18nTexts = {
+ save: isEditing ? (
+
+ ) : (
+
+ ),
+ };
+
+ const apiError = saveError ? (
+ <>
+
+ }
+ color="danger"
+ iconType="alert"
+ data-test-subj="saveComponentTemplateError"
+ >
+ {saveError.message || saveError.statusText}
+
+
+ >
+ ) : null;
+
+ const buildComponentTemplateObject = (initialTemplate: ComponentTemplateDeserialized) => (
+ wizardData: WizardContent
+ ): ComponentTemplateDeserialized => {
+ const componentTemplate = {
+ ...initialTemplate,
+ name: wizardData.logistics.name,
+ version: wizardData.logistics.version,
+ _meta: wizardData.logistics._meta,
+ template: {
+ settings: wizardData.settings,
+ mappings: wizardData.mappings,
+ aliases: wizardData.aliases,
+ },
+ };
+ return componentTemplate;
+ };
+
+ const onSaveComponentTemplate = useCallback(
+ async (wizardData: WizardContent) => {
+ const componentTemplate = buildComponentTemplateObject(defaultValue)(wizardData);
+
+ // This will strip an empty string if "version" is not set, as well as an empty "_meta" object
+ onSave(
+ stripEmptyFields(componentTemplate, {
+ types: ['string', 'object'],
+ }) as ComponentTemplateDeserialized
+ );
+
+ clearSaveError();
+ },
+ [defaultValue, onSave, clearSaveError]
+ );
+
+ return (
+
+ defaultValue={wizardDefaultValue}
+ onSave={onSaveComponentTemplate}
+ isEditing={isEditing}
+ isSaving={isSaving}
+ apiError={apiError}
+ texts={i18nTexts}
+ >
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+};
diff --git a/x-pack/plugins/index_management/public/application/components/component_templates/component_template_wizard/component_template_form/index.ts b/x-pack/plugins/index_management/public/application/components/component_templates/component_template_wizard/component_template_form/index.ts
new file mode 100644
index 000000000000..84d9a2795ee2
--- /dev/null
+++ b/x-pack/plugins/index_management/public/application/components/component_templates/component_template_wizard/component_template_form/index.ts
@@ -0,0 +1,7 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+export { ComponentTemplateForm } from './component_template_form';
diff --git a/x-pack/plugins/index_management/public/application/components/component_templates/component_template_wizard/component_template_form/steps/index.ts b/x-pack/plugins/index_management/public/application/components/component_templates/component_template_wizard/component_template_form/steps/index.ts
new file mode 100644
index 000000000000..b7e3e36e6181
--- /dev/null
+++ b/x-pack/plugins/index_management/public/application/components/component_templates/component_template_wizard/component_template_form/steps/index.ts
@@ -0,0 +1,8 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+export { StepLogisticsContainer } from './step_logistics_container';
+export { StepReviewContainer } from './step_review_container';
diff --git a/x-pack/plugins/index_management/public/application/components/component_templates/component_template_wizard/component_template_form/steps/step_logistics.tsx b/x-pack/plugins/index_management/public/application/components/component_templates/component_template_wizard/component_template_form/steps/step_logistics.tsx
new file mode 100644
index 000000000000..8762eae9d229
--- /dev/null
+++ b/x-pack/plugins/index_management/public/application/components/component_templates/component_template_wizard/component_template_form/steps/step_logistics.tsx
@@ -0,0 +1,229 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+import React, { useEffect, useState } from 'react';
+import {
+ EuiFlexGroup,
+ EuiFlexItem,
+ EuiTitle,
+ EuiButtonEmpty,
+ EuiSpacer,
+ EuiSwitch,
+ EuiLink,
+} from '@elastic/eui';
+import { FormattedMessage } from '@kbn/i18n/react';
+import { i18n } from '@kbn/i18n';
+import {
+ useForm,
+ Form,
+ getUseField,
+ getFormRow,
+ Field,
+ Forms,
+ JsonEditorField,
+} from '../../../shared_imports';
+import { useComponentTemplatesContext } from '../../../component_templates_context';
+import { logisticsFormSchema } from './step_logistics_schema';
+
+const UseField = getUseField({ component: Field });
+const FormRow = getFormRow({ titleTag: 'h3' });
+
+interface Props {
+ defaultValue: { [key: string]: any };
+ onChange: (content: Forms.Content) => void;
+ isEditing?: boolean;
+}
+
+export const StepLogistics: React.FunctionComponent = React.memo(
+ ({ defaultValue, isEditing, onChange }) => {
+ const { form } = useForm({
+ schema: logisticsFormSchema,
+ defaultValue,
+ options: { stripEmptyFields: false },
+ });
+
+ const { documentation } = useComponentTemplatesContext();
+
+ const [isMetaVisible, setIsMetaVisible] = useState(
+ Boolean(defaultValue._meta && Object.keys(defaultValue._meta).length)
+ );
+
+ const validate = async () => {
+ return (await form.submit()).isValid;
+ };
+
+ useEffect(() => {
+ onChange({
+ isValid: form.isValid,
+ validate,
+ getData: form.getFormData,
+ });
+ }, [form.isValid, onChange]); // eslint-disable-line react-hooks/exhaustive-deps
+
+ useEffect(() => {
+ const subscription = form.subscribe(({ data, isValid }) => {
+ onChange({
+ isValid,
+ validate,
+ getData: data.format,
+ });
+ });
+ return subscription.unsubscribe;
+ }, [onChange]); // eslint-disable-line react-hooks/exhaustive-deps
+
+ return (
+