[Component templates] Form wizard (#69732)

This commit is contained in:
Alison Goryachev 2020-07-06 18:14:59 -04:00 committed by GitHub
parent 2eb0896415
commit e35a42aa07
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
47 changed files with 2203 additions and 111 deletions

View file

@ -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',
},
},
},
},
});
});
});
});

View file

@ -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,
};
}

View file

@ -20,4 +20,5 @@ export { getTemplateParameter } from './utils';
export {
deserializeComponentTemplate,
deserializeComponenTemplateList,
serializeComponentTemplate,
} from './component_template_serialization';

View file

@ -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 = () => (
<Route exact path="/create_template" component={TemplateCreate} />
<Route exact path="/clone_template/:name*" component={TemplateClone} />
<Route exact path="/edit_template/:name*" component={TemplateEdit} />
<Route exact path="/create_component_template" component={ComponentTemplateCreate} />
<Route
exact
path="/create_component_template/:sourceComponentTemplateName"
component={ComponentTemplateClone}
/>
<Route exact path="/edit_component_template/:name*" component={ComponentTemplateEdit} />
<Route path={`/:section(${homeSections.join('|')})`} component={IndexManagementHome} />
<Redirect from={`/`} to={`/indices`} />
</Switch>

View file

@ -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 = ({

View file

@ -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) => (
<input
data-test-subj="mockComboBox"
onChange={(syntheticEvent: any) => {
props.onChange([syntheticEvent['0']]);
}}
/>
),
// Mocking EuiCodeEditor, which uses React Ace under the hood
EuiCodeEditor: (props: any) => (
<input
data-test-subj="mockCodeEditor"
onChange={(syntheticEvent: any) => {
props.onChange(syntheticEvent.jsonString);
}}
/>
),
};
});
describe('<ComponentTemplateCreate />', () => {
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);
});
});
});
});

View file

@ -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) => (
<input
data-test-subj="mockComboBox"
onChange={(syntheticEvent: any) => {
props.onChange([syntheticEvent['0']]);
}}
/>
),
// Mocking EuiCodeEditor, which uses React Ace under the hood
EuiCodeEditor: (props: any) => (
<input
data-test-subj="mockCodeEditor"
onChange={(syntheticEvent: any) => {
props.onChange(syntheticEvent.jsonString);
}}
/>
),
};
});
describe('<ComponentTemplateEdit />', () => {
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);
});
});
});

View file

@ -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<ComponentTemplateFormTestSubjects> & {
actions: ReturnType<typeof getFormActions>;
};
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<ComponentTemplateCreateTestBed> => {
const testBed = await initTestBed();
return {
...testBed,
actions: getFormActions(testBed),
};
};

View file

@ -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<ComponentTemplateFormTestSubjects> & {
actions: ReturnType<typeof getFormActions>;
};
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<ComponentTemplateEditTestBed> => {
const testBed = await initTestBed();
return {
...testBed,
actions: getFormActions(testBed),
};
};

View file

@ -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';

View file

@ -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,
};
};

View file

@ -27,6 +27,7 @@ const appDependencies = {
trackMetric: () => {},
docLinks: docLinksServiceMock.createStartContract(),
toasts: notificationServiceMock.createSetupContract().toasts,
setBreadcrumbs: () => {},
};
export const setupEnvironment = () => {

View file

@ -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<Props> = ({
}) => {
const { api } = useComponentTemplatesContext();
const decodedComponentTemplateName = attemptToDecodeURI(componentTemplateName);
const { data: componentTemplateDetails, isLoading, error } = api.useLoadComponentTemplate(
componentTemplateName
decodedComponentTemplateName
);
const [activeTab, setActiveTab] = useState<TabType>('summary');
@ -108,7 +111,7 @@ export const ComponentTemplateDetailsFlyout: React.FunctionComponent<Props> = ({
<EuiFlyoutHeader>
<EuiTitle size="m">
<h2 id="componentTemplateDetailsFlyoutTitle" data-test-subj="title">
{componentTemplateName}
{decodedComponentTemplateName}
</h2>
</EuiTitle>
</EuiFlyoutHeader>

View file

@ -74,7 +74,7 @@ export const TabSummary: React.FunctionComponent<Props> = ({ componentTemplateDe
)}
{/* Version (optional) */}
{version && (
{typeof version !== 'undefined' && (
<>
<EuiDescriptionListTitle data-test-subj="versionTitle">
<FormattedMessage

View file

@ -12,6 +12,7 @@ import { ScopedHistory } from 'kibana/public';
import { SectionLoading, ComponentTemplateDeserialized } from '../shared_imports';
import { UIM_COMPONENT_TEMPLATE_LIST_LOAD } from '../constants';
import { attemptToDecodeURI } from '../lib';
import { useComponentTemplatesContext } from '../component_templates_context';
import { ComponentTemplateDetailsFlyout } from '../component_template_details';
import { EmptyPrompt } from './empty_prompt';
@ -34,8 +35,22 @@ export const ComponentTemplateList: React.FunctionComponent<Props> = ({
const [componentTemplatesToDelete, setComponentTemplatesToDelete] = useState<string[]>([]);
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<Props> = ({
componentTemplates={data}
onReloadClick={sendRequest}
onDeleteClick={setComponentTemplatesToDelete}
onEditClick={goToEditComponentTemplate}
onCloneClick={goToCloneComponentTemplate}
history={history as ScopedHistory}
/>
);
} else if (data && data.length === 0) {
content = <EmptyPrompt />;
content = <EmptyPrompt history={history} />;
} else if (error) {
content = <LoadError onReloadClick={sendRequest} />;
}
@ -81,7 +98,7 @@ export const ComponentTemplateList: React.FunctionComponent<Props> = ({
// 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<Props> = ({
{/* details flyout */}
{componentTemplateName && (
<ComponentTemplateDetailsFlyout
onClose={goToList}
onClose={goToComponentTemplateList}
componentTemplateName={componentTemplateName}
actions={[
{
name: i18n.translate('xpack.idxMgmt.componentTemplateDetails.editButtonLabel', {
defaultMessage: 'Edit',
}),
icon: 'pencil',
handleActionClick: () =>
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<Props> = ({
details._kbnMeta.usedBy.length > 0,
closePopoverOnClick: true,
handleActionClick: () => {
setComponentTemplatesToDelete([componentTemplateName]);
setComponentTemplatesToDelete([attemptToDecodeURI(componentTemplateName)]);
},
},
]}

View file

@ -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<Props> = ({ history }) => {
const { documentation } = useComponentTemplatesContext();
return (
@ -38,6 +44,17 @@ export const EmptyPrompt: FunctionComponent = () => {
</EuiLink>
</p>
}
actions={
<EuiButton
{...reactRouterNavigate(history, '/create_component_template')}
iconType="plusInCircle"
fill
>
{i18n.translate('xpack.idxMgmt.home.componentTemplates.emptyPromptButtonLabel', {
defaultMessage: 'Create a component template',
})}
</EuiButton>
}
/>
);
};

View file

@ -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<Props> = ({
componentTemplates,
onReloadClick,
onDeleteClick,
onEditClick,
onCloneClick,
history,
}) => {
const { trackMetric } = useComponentTemplatesContext();
@ -85,6 +89,17 @@ export const ComponentTable: FunctionComponent<Props> = ({
defaultMessage: 'Reload',
})}
</EuiButton>,
<EuiButton
fill
iconType="plusInCircle"
data-test-subj="createPipelineButton"
key="createPipelineButton"
{...reactRouterNavigate(history, '/create_component_template')}
>
{i18n.translate('xpack.idxMgmt.componentTemplatesList.table.createButtonLabel', {
defaultMessage: 'Create a component template',
})}
</EuiButton>,
],
box: {
incremental: true,
@ -135,7 +150,7 @@ export const ComponentTable: FunctionComponent<Props> = ({
{...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<Props> = ({
),
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<Props> = ({
'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',
},
],
},

View file

@ -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<RouteComponentProps<Params>> = (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 (
<SectionLoading>
<FormattedMessage
id="xpack.idxMgmt.componentTemplateEdit.loadingDescription"
defaultMessage="Loading component template…"
/>
</SectionLoading>
);
} 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 <ComponentTemplateCreate {...props} sourceComponentTemplate={sourceComponentTemplate} />;
}
};

View file

@ -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';

View file

@ -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<RouteComponentProps & Props> = ({
history,
sourceComponentTemplate,
}) => {
const [isSaving, setIsSaving] = useState<boolean>(false);
const [saveError, setSaveError] = useState<any>(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 (
<EuiPageBody>
<EuiPageContent>
<EuiTitle size="l">
<h1 data-test-subj="pageTitle">
<FormattedMessage
id="xpack.idxMgmt.createComponentTemplate.pageTitle"
defaultMessage="Create component template"
/>
</h1>
</EuiTitle>
<EuiSpacer size="l" />
<ComponentTemplateForm
defaultValue={sourceComponentTemplate}
onSave={onSave}
isSaving={isSaving}
saveError={saveError}
clearSaveError={clearSaveError}
/>
</EuiPageContent>
</EuiPageBody>
);
};

View file

@ -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';

View file

@ -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<RouteComponentProps<MatchParams>> = ({
match: {
params: { name },
},
history,
}) => {
const { api, breadcrumbs } = useComponentTemplatesContext();
const [isSaving, setIsSaving] = useState<boolean>(false);
const [saveError, setSaveError] = useState<any>(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 = (
<SectionLoading>
<FormattedMessage
id="xpack.idxMgmt.componentTemplateEdit.loadingDescription"
defaultMessage="Loading component template…"
/>
</SectionLoading>
);
} else if (error) {
content = (
<>
<EuiCallOut
title={
<FormattedMessage
id="xpack.idxMgmt.componentTemplateEdit.loadComponentTemplateError"
defaultMessage="Error loading component template"
/>
}
color="danger"
iconType="alert"
data-test-subj="loadComponentTemplateError"
>
<div>{error.message}</div>
</EuiCallOut>
<EuiSpacer size="m" />
</>
);
} else if (componentTemplate) {
content = (
<ComponentTemplateForm
defaultValue={componentTemplate}
onSave={onSave}
isSaving={isSaving}
saveError={saveError}
clearSaveError={clearSaveError}
isEditing={true}
/>
);
}
return (
<EuiPageBody>
<EuiPageContent>
<EuiTitle size="l">
<h1 data-test-subj="pageTitle">
<FormattedMessage
id="xpack.idxMgmt.componentTemplateEdit.editPageTitle"
defaultMessage="Edit component template '{name}'"
values={{ name: decodedName }}
/>
</h1>
</EuiTitle>
<EuiSpacer size="l" />
{content}
</EuiPageContent>
</EuiPageBody>
);
};

View file

@ -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';

View file

@ -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<ComponentTemplateDeserialized, '_kbnMeta' | 'template'>;
}
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 ? (
<FormattedMessage
id="xpack.idxMgmt.componentTemplateForm.saveButtonLabel"
defaultMessage="Save component template"
/>
) : (
<FormattedMessage
id="xpack.idxMgmt.componentTemplateForm.createButtonLabel"
defaultMessage="Create component template"
/>
),
};
const apiError = saveError ? (
<>
<EuiCallOut
title={
<FormattedMessage
id="xpack.idxMgmt.componentTemplateForm.saveTemplateError"
defaultMessage="Unable to create component template"
/>
}
color="danger"
iconType="alert"
data-test-subj="saveComponentTemplateError"
>
<div>{saveError.message || saveError.statusText}</div>
</EuiCallOut>
<EuiSpacer size="m" />
</>
) : 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 (
<FormWizard<WizardContent>
defaultValue={wizardDefaultValue}
onSave={onSaveComponentTemplate}
isEditing={isEditing}
isSaving={isSaving}
apiError={apiError}
texts={i18nTexts}
>
<FormWizardStep
id={wizardSections.logistics.id}
label={wizardSections.logistics.label}
isRequired
>
<StepLogisticsContainer isEditing={isEditing} />
</FormWizardStep>
<FormWizardStep id={wizardSections.settings.id} label={wizardSections.settings.label}>
<StepSettingsContainer esDocsBase={documentation.esDocsBase} />
</FormWizardStep>
<FormWizardStep id={wizardSections.mappings.id} label={wizardSections.mappings.label}>
<StepMappingsContainer esDocsBase={documentation.esDocsBase} />
</FormWizardStep>
<FormWizardStep id={wizardSections.aliases.id} label={wizardSections.aliases.label}>
<StepAliasesContainer esDocsBase={documentation.esDocsBase} />
</FormWizardStep>
<FormWizardStep id={wizardSections.review.id} label={wizardSections.review.label}>
<StepReviewContainer
getComponentTemplateData={buildComponentTemplateObject(defaultValue)}
/>
</FormWizardStep>
</FormWizard>
);
};

View file

@ -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';

View file

@ -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';

View file

@ -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<Props> = React.memo(
({ defaultValue, isEditing, onChange }) => {
const { form } = useForm({
schema: logisticsFormSchema,
defaultValue,
options: { stripEmptyFields: false },
});
const { documentation } = useComponentTemplatesContext();
const [isMetaVisible, setIsMetaVisible] = useState<boolean>(
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 (
<Form form={form} data-test-subj="stepLogistics">
<EuiFlexGroup justifyContent="spaceBetween">
<EuiFlexItem grow={false}>
<EuiTitle>
<h2>
<FormattedMessage
id="xpack.idxMgmt.componentTemplateForm.stepLogistics.stepTitle"
defaultMessage="Logistics"
/>
</h2>
</EuiTitle>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButtonEmpty
size="s"
flush="right"
href={documentation.componentTemplates}
target="_blank"
iconType="help"
data-test-subj="documentationLink"
>
<FormattedMessage
id="xpack.idxMgmt.componentTemplateForm.stepLogistics.docsButtonLabel"
defaultMessage="Component Templates docs"
/>
</EuiButtonEmpty>
</EuiFlexItem>
</EuiFlexGroup>
<EuiSpacer size="l" />
{/* Name field */}
<FormRow
title={
<FormattedMessage
id="xpack.idxMgmt.componentTemplateForm.stepLogistics.nameTitle"
defaultMessage="Name"
/>
}
description={
<FormattedMessage
id="xpack.idxMgmt.componentTemplateForm.stepLogistics.nameDescription"
defaultMessage="A unique identifier for this component template."
/>
}
>
<UseField
path="name"
componentProps={{
['data-test-subj']: 'nameField',
euiFieldProps: { disabled: isEditing },
}}
/>
</FormRow>
{/* version field */}
<FormRow
title={
<FormattedMessage
id="xpack.idxMgmt.componentTemplateForm.stepLogistics.versionTitle"
defaultMessage="Version"
/>
}
description={
<FormattedMessage
id="xpack.idxMgmt.componentTemplateForm.stepLogistics.versionDescription"
defaultMessage="A number that identifies the component template to external management systems."
/>
}
>
<UseField
path="version"
componentProps={{
['data-test-subj']: 'versionField',
}}
/>
</FormRow>
{/* _meta field */}
<FormRow
title={
<FormattedMessage
id="xpack.idxMgmt.componentTemplateForm.stepLogistics.metaTitle"
defaultMessage="Metadata"
/>
}
description={
<>
<FormattedMessage
id="xpack.idxMgmt.componentTemplateForm.stepLogistics.metaDescription"
defaultMessage="Arbitrary metadata that is stored in cluster state. {learnMoreLink}"
values={{
learnMoreLink: (
<EuiLink
href={documentation.componentTemplatesMetadata}
target="_blank"
external
>
{i18n.translate(
'xpack.idxMgmt.componentTemplateForm.stepLogistics.metaDocumentionLink',
{
defaultMessage: 'Learn more',
}
)}
</EuiLink>
),
}}
/>
<EuiSpacer size="m" />
<EuiSwitch
label={
<FormattedMessage
id="xpack.idxMgmt.componentTemplateForm.stepLogistics.metadataDescription"
defaultMessage="Add metadata"
/>
}
checked={isMetaVisible}
onChange={(e) => setIsMetaVisible(e.target.checked)}
data-test-subj="metaToggle"
/>
</>
}
>
{isMetaVisible ? (
<UseField
path="_meta"
component={JsonEditorField}
componentProps={{
euiCodeEditorProps: {
['data-test-subj']: 'metaEditor',
height: '200px',
'aria-label': i18n.translate(
'xpack.idxMgmt.componentTemplateForm.stepLogistics.metaAriaLabel',
{
defaultMessage: 'Metadata JSON editor',
}
),
},
}}
/>
) : (
// <FormRow/> requires children or a field
// For now, we return an empty <div> if the editor is not visible
<div />
)}
</FormRow>
</Form>
);
}
);

View file

@ -0,0 +1,22 @@
/*
* 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 { Forms } from '../../../shared_imports';
import { WizardContent } from '../component_template_form';
import { StepLogistics } from './step_logistics';
interface Props {
isEditing?: boolean;
}
export const StepLogisticsContainer = ({ isEditing = false }: Props) => {
const { defaultValue, updateContent } = Forms.useContent<WizardContent, 'logistics'>('logistics');
return (
<StepLogistics defaultValue={defaultValue} onChange={updateContent} isEditing={isEditing} />
);
};

View file

@ -0,0 +1,102 @@
/*
* 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 { EuiCode } from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n/react';
import { i18n } from '@kbn/i18n';
import { FIELD_TYPES, fieldValidators, fieldFormatters, FormSchema } from '../../../shared_imports';
const { emptyField, containsCharsField, isJsonField } = fieldValidators;
const { toInt } = fieldFormatters;
const stringifyJson = (json: { [key: string]: any }): string =>
Object.keys(json).length ? JSON.stringify(json, null, 2) : '{\n\n}';
const parseJson = (jsonString: string): object => {
let parsedJSON: any;
try {
parsedJSON = JSON.parse(jsonString);
} catch {
parsedJSON = {};
}
return parsedJSON;
};
export const logisticsFormSchema: FormSchema = {
name: {
defaultValue: undefined,
label: i18n.translate('xpack.idxMgmt.componentTemplateForm.stepLogistics.nameFieldLabel', {
defaultMessage: 'Name',
}),
type: FIELD_TYPES.TEXT,
validations: [
{
validator: emptyField(
i18n.translate('xpack.idxMgmt.componentTemplateForm.validation.nameRequiredError', {
defaultMessage: 'A component template name is required.',
})
),
},
{
validator: containsCharsField({
chars: ' ',
message: i18n.translate(
'xpack.idxMgmt.componentTemplateForm.stepLogistics.validation.nameSpacesError',
{
defaultMessage: 'Spaces are not allowed in a component template name.',
}
),
}),
},
],
},
version: {
type: FIELD_TYPES.NUMBER,
label: i18n.translate('xpack.idxMgmt.componentTemplateForm.stepLogistics.versionFieldLabel', {
defaultMessage: 'Version (optional)',
}),
formatters: [toInt],
},
_meta: {
label: i18n.translate('xpack.idxMgmt.componentTemplateForm.stepLogistics.metaFieldLabel', {
defaultMessage: 'Metadata (optional)',
}),
helpText: (
<FormattedMessage
id="xpack.idxMgmt.componentTemplateForm.stepLogistics.metaHelpText"
defaultMessage="Use JSON format: {code}"
values={{
code: <EuiCode>{JSON.stringify({ arbitrary_data: 'anything_goes' })}</EuiCode>,
}}
/>
),
serializer: (value) => {
const result = parseJson(value);
// If an empty object was passed, strip out this value entirely.
if (!Object.keys(result).length) {
return undefined;
}
return result;
},
deserializer: stringifyJson,
validations: [
{
validator: isJsonField(
i18n.translate(
'xpack.idxMgmt.componentTemplateForm.stepLogistics.validation.metaJsonError',
{
defaultMessage: 'The input is not valid.',
}
),
{ allowEmptyString: true }
),
},
],
},
};

View file

@ -0,0 +1,212 @@
/*
* 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 { i18n } from '@kbn/i18n';
import {
EuiFlexGroup,
EuiTitle,
EuiFlexItem,
EuiSpacer,
EuiTabbedContent,
EuiDescriptionList,
EuiDescriptionListTitle,
EuiDescriptionListDescription,
EuiText,
EuiCodeBlock,
} from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n/react';
import {
ComponentTemplateDeserialized,
serializers,
serializeComponentTemplate,
} from '../../../shared_imports';
const { stripEmptyFields } = serializers;
const getDescriptionText = (data: any) => {
const hasEntries = data && Object.entries(data).length > 0;
return hasEntries ? (
<FormattedMessage
id="xpack.idxMgmt.componentTemplateForm.stepReview.summaryTab.yesDescriptionText"
defaultMessage="Yes"
/>
) : (
<FormattedMessage
id="xpack.idxMgmt.componentTemplateForm.stepReview.summaryTab.noDescriptionText"
defaultMessage="No"
/>
);
};
interface Props {
componentTemplate: ComponentTemplateDeserialized;
}
export const StepReview: React.FunctionComponent<Props> = React.memo(({ componentTemplate }) => {
const { name } = componentTemplate;
const serializedComponentTemplate = serializeComponentTemplate(
stripEmptyFields(componentTemplate, {
types: ['string', 'object'],
}) as ComponentTemplateDeserialized
);
const {
template: {
mappings: serializedMappings,
settings: serializedSettings,
aliases: serializedAliases,
},
_meta: serializedMeta,
version: serializedVersion,
} = serializedComponentTemplate;
const SummaryTab = () => (
<div data-test-subj="summaryTab">
<EuiSpacer size="m" />
<EuiFlexGroup>
<EuiFlexItem>
<EuiDescriptionList textStyle="reverse">
{/* Version */}
{typeof serializedVersion !== 'undefined' && (
<>
<EuiDescriptionListTitle>
<FormattedMessage
id="xpack.idxMgmt.templateForm.stepReview.summaryTab.versionLabel"
defaultMessage="Version"
/>
</EuiDescriptionListTitle>
<EuiDescriptionListDescription>{serializedVersion}</EuiDescriptionListDescription>
</>
)}
{/* Index settings */}
<EuiDescriptionListTitle>
<FormattedMessage
id="xpack.idxMgmt.componentTemplateForm.stepReview.summaryTab.settingsLabel"
defaultMessage="Index settings"
/>
</EuiDescriptionListTitle>
<EuiDescriptionListDescription>
{getDescriptionText(serializedSettings)}
</EuiDescriptionListDescription>
{/* Mappings */}
<EuiDescriptionListTitle>
<FormattedMessage
id="xpack.idxMgmt.componentTemplateForm.stepReview.summaryTab.mappingLabel"
defaultMessage="Mappings"
/>
</EuiDescriptionListTitle>
<EuiDescriptionListDescription>
{getDescriptionText(serializedMappings)}
</EuiDescriptionListDescription>
{/* Aliases */}
<EuiDescriptionListTitle>
<FormattedMessage
id="xpack.idxMgmt.componentTemplateForm.stepReview.summaryTab.aliasesLabel"
defaultMessage="Aliases"
/>
</EuiDescriptionListTitle>
<EuiDescriptionListDescription>
{getDescriptionText(serializedAliases)}
</EuiDescriptionListDescription>
</EuiDescriptionList>
</EuiFlexItem>
<EuiFlexItem>
{/* Metadata */}
{serializedMeta && (
<EuiDescriptionList textStyle="reverse">
<EuiDescriptionListTitle>
<FormattedMessage
id="xpack.idxMgmt.templateForm.stepReview.summaryTab.metaLabel"
defaultMessage="Metadata"
/>
</EuiDescriptionListTitle>
<EuiDescriptionListDescription>
<EuiCodeBlock language="json">
{JSON.stringify(serializedMeta, null, 2)}
</EuiCodeBlock>
</EuiDescriptionListDescription>
</EuiDescriptionList>
)}
</EuiFlexItem>
</EuiFlexGroup>
</div>
);
const RequestTab = () => {
const endpoint = `PUT _component_template/${name || '<componentTemplateName>'}`;
const templateString = JSON.stringify(serializedComponentTemplate, null, 2);
const request = `${endpoint}\n${templateString}`;
// Beyond a certain point, highlighting the syntax will bog down performance to unacceptable
// levels. This way we prevent that happening for very large requests.
const language = request.length < 60000 ? 'json' : undefined;
return (
<div data-test-subj="requestTab">
<EuiSpacer size="m" />
<EuiText>
<p>
<FormattedMessage
id="xpack.idxMgmt.componentTemplateForm.stepReview.requestTab.descriptionText"
defaultMessage="This request will create the following component template."
/>
</p>
</EuiText>
<EuiSpacer size="m" />
<EuiCodeBlock language={language} isCopyable>
{request}
</EuiCodeBlock>
</div>
);
};
return (
<div data-test-subj="stepReview">
<EuiTitle>
<h2 data-test-subj="title">
<FormattedMessage
id="xpack.idxMgmt.componentTemplateForm.stepReview.stepTitle"
defaultMessage="Review details for '{templateName}'"
values={{ templateName: name }}
/>
</h2>
</EuiTitle>
<EuiSpacer size="l" />
<EuiTabbedContent
data-test-subj="content"
tabs={[
{
id: 'summary',
name: i18n.translate('xpack.idxMgmt.componentTemplateForm.stepReview.summaryTabTitle', {
defaultMessage: 'Summary',
}),
content: <SummaryTab />,
},
{
id: 'request',
name: i18n.translate('xpack.idxMgmt.componentTemplateForm.stepReview.requestTabTitle', {
defaultMessage: 'Request',
}),
content: <RequestTab />,
},
]}
/>
</div>
);
});

View file

@ -0,0 +1,24 @@
/*
* 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 { Forms, ComponentTemplateDeserialized } from '../../../shared_imports';
import { WizardContent } from '../component_template_form';
import { StepReview } from './step_review';
interface Props {
getComponentTemplateData: (wizardContent: WizardContent) => ComponentTemplateDeserialized;
}
export const StepReviewContainer = React.memo(({ getComponentTemplateData }: Props) => {
const { getData } = Forms.useMultiContentContext<WizardContent>();
const wizardContent = getData();
// Build the final template object, providing the wizard content data
const componentTemplate = getComponentTemplateData(wizardContent);
return <StepReview componentTemplate={componentTemplate} />;
});

View file

@ -0,0 +1,11 @@
/*
* 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';
export { ComponentTemplateEdit } from './component_template_edit';
export { ComponentTemplateClone } from './component_template_clone';

View file

@ -7,7 +7,8 @@
import React, { createContext, useContext } from 'react';
import { HttpSetup, DocLinksStart, NotificationsSetup } from 'src/core/public';
import { getApi, getUseRequest, getSendRequest, getDocumentation } from './lib';
import { ManagementAppMountParams } from 'src/plugins/management/public';
import { getApi, getUseRequest, getSendRequest, getDocumentation, getBreadcrumbs } from './lib';
const ComponentTemplatesContext = createContext<Context | undefined>(undefined);
@ -17,6 +18,7 @@ interface Props {
trackMetric: (type: 'loaded' | 'click' | 'count', eventName: string) => void;
docLinks: DocLinksStart;
toasts: NotificationsSetup['toasts'];
setBreadcrumbs: ManagementAppMountParams['setBreadcrumbs'];
}
interface Context {
@ -24,6 +26,7 @@ interface Context {
apiBasePath: string;
api: ReturnType<typeof getApi>;
documentation: ReturnType<typeof getDocumentation>;
breadcrumbs: ReturnType<typeof getBreadcrumbs>;
trackMetric: (type: 'loaded' | 'click' | 'count', eventName: string) => void;
toasts: NotificationsSetup['toasts'];
}
@ -35,17 +38,18 @@ export const ComponentTemplatesProvider = ({
value: Props;
children: React.ReactNode;
}) => {
const { httpClient, apiBasePath, trackMetric, docLinks, toasts } = value;
const { httpClient, apiBasePath, trackMetric, docLinks, toasts, setBreadcrumbs } = value;
const useRequest = getUseRequest(httpClient);
const sendRequest = getSendRequest(httpClient);
const api = getApi(useRequest, sendRequest, apiBasePath, trackMetric);
const documentation = getDocumentation(docLinks);
const breadcrumbs = getBreadcrumbs(setBreadcrumbs);
return (
<ComponentTemplatesContext.Provider
value={{ api, documentation, trackMetric, toasts, httpClient, apiBasePath }}
value={{ api, documentation, trackMetric, toasts, httpClient, apiBasePath, breadcrumbs }}
>
{children}
</ComponentTemplatesContext.Provider>

View file

@ -9,6 +9,8 @@ export const UIM_COMPONENT_TEMPLATE_LIST_LOAD = 'component_template_list_load';
export const UIM_COMPONENT_TEMPLATE_DELETE = 'component_template_delete';
export const UIM_COMPONENT_TEMPLATE_DELETE_MANY = 'component_template_delete_many';
export const UIM_COMPONENT_TEMPLATE_DETAILS = 'component_template_details';
export const UIM_COMPONENT_TEMPLATE_CREATE = 'component_template_create';
export const UIM_COMPONENT_TEMPLATE_UPDATE = 'component_template_update';
// privileges
export const APP_CLUSTER_REQUIRED_PRIVILEGES = ['manage_index_templates'];

View file

@ -10,4 +10,10 @@ export { ComponentTemplateList } from './component_template_list';
export { ComponentTemplateDetailsFlyout } from './component_template_details';
export {
ComponentTemplateCreate,
ComponentTemplateEdit,
ComponentTemplateClone,
} from './component_template_wizard';
export * from './component_template_selector';

View file

@ -4,8 +4,18 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { ComponentTemplateListItem, ComponentTemplateDeserialized, Error } from '../shared_imports';
import { UIM_COMPONENT_TEMPLATE_DELETE_MANY, UIM_COMPONENT_TEMPLATE_DELETE } from '../constants';
import {
ComponentTemplateListItem,
ComponentTemplateDeserialized,
ComponentTemplateSerialized,
Error,
} from '../shared_imports';
import {
UIM_COMPONENT_TEMPLATE_DELETE_MANY,
UIM_COMPONENT_TEMPLATE_DELETE,
UIM_COMPONENT_TEMPLATE_CREATE,
UIM_COMPONENT_TEMPLATE_UPDATE,
} from '../constants';
import { UseRequestHook, SendRequestHook } from './request';
export const getApi = (
@ -44,9 +54,36 @@ export const getApi = (
});
}
async function createComponentTemplate(componentTemplate: ComponentTemplateSerialized) {
const result = await sendRequest({
path: `${apiBasePath}/component_templates`,
method: 'post',
body: JSON.stringify(componentTemplate),
});
trackMetric('count', UIM_COMPONENT_TEMPLATE_CREATE);
return result;
}
async function updateComponentTemplate(componentTemplate: ComponentTemplateDeserialized) {
const { name } = componentTemplate;
const result = await sendRequest({
path: `${apiBasePath}/component_templates/${encodeURIComponent(name)}`,
method: 'put',
body: JSON.stringify(componentTemplate),
});
trackMetric('count', UIM_COMPONENT_TEMPLATE_UPDATE);
return result;
}
return {
useLoadComponentTemplates,
deleteComponentTemplates,
useLoadComponentTemplate,
createComponentTemplate,
updateComponentTemplate,
};
};

View file

@ -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 { i18n } from '@kbn/i18n';
import { ManagementAppMountParams } from 'src/plugins/management/public';
export const getBreadcrumbs = (setBreadcrumbs: ManagementAppMountParams['setBreadcrumbs']) => {
const baseBreadcrumbs = [
{
text: i18n.translate('xpack.idxMgmt.componentTemplate.breadcrumb.homeLabel', {
defaultMessage: 'Index Management',
}),
href: '/',
},
{
text: i18n.translate('xpack.idxMgmt.componentTemplate.breadcrumb.componentTemplatesLabel', {
defaultMessage: 'Component templates',
}),
href: '/component_templates',
},
];
const setCreateBreadcrumbs = () => {
const createBreadcrumbs = [
...baseBreadcrumbs,
{
text: i18n.translate(
'xpack.idxMgmt.componentTemplate.breadcrumb.createComponentTemplateLabel',
{
defaultMessage: 'Create component template',
}
),
},
];
return setBreadcrumbs(createBreadcrumbs);
};
const setEditBreadcrumbs = () => {
const editBreadcrumbs = [
...baseBreadcrumbs,
{
text: i18n.translate(
'xpack.idxMgmt.componentTemplate.breadcrumb.editComponentTemplateLabel',
{
defaultMessage: 'Edit component template',
}
),
},
];
return setBreadcrumbs(editBreadcrumbs);
};
return {
setCreateBreadcrumbs,
setEditBreadcrumbs,
};
};

View file

@ -11,6 +11,8 @@ export const getDocumentation = ({ ELASTIC_WEBSITE_URL, DOC_LINK_VERSION }: DocL
const esDocsBase = `${docsBase}/elasticsearch/reference/${DOC_LINK_VERSION}`;
return {
esDocsBase,
componentTemplates: `${esDocsBase}/indices-component-template.html`,
componentTemplatesMetadata: `${esDocsBase}/indices-component-template.html#component-templates-metadata`,
};
};

View file

@ -9,3 +9,7 @@ export * from './api';
export * from './request';
export * from './documentation';
export * from './breadcrumbs';
export { attemptToDecodeURI } from './utils';

View file

@ -0,0 +1,18 @@
/*
* 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 const attemptToDecodeURI = (value: string) => {
let result: string;
try {
result = decodeURI(value);
result = decodeURIComponent(result);
} catch (e) {
result = decodeURIComponent(value);
}
return result;
};

View file

@ -21,10 +21,44 @@ export {
Forms,
} from '../../../../../../../src/plugins/es_ui_shared/public';
export { TabMappings, TabSettings, TabAliases } from '../shared';
export {
serializers,
fieldValidators,
fieldFormatters,
} from '../../../../../../../src/plugins/es_ui_shared/static/forms/helpers';
export {
FormSchema,
FIELD_TYPES,
VALIDATION_TYPES,
FieldConfig,
useForm,
Form,
getUseField,
} from '../../../../../../../src/plugins/es_ui_shared/static/forms/hook_form_lib';
export {
getFormRow,
Field,
JsonEditorField,
} from '../../../../../../../src/plugins/es_ui_shared/static/forms/components';
export { isJSON } from '../../../../../../../src/plugins/es_ui_shared/static/validators/string';
export {
TabMappings,
TabSettings,
TabAliases,
CommonWizardSteps,
StepSettingsContainer,
StepMappingsContainer,
StepAliasesContainer,
} from '../shared';
export {
ComponentTemplateSerialized,
ComponentTemplateDeserialized,
ComponentTemplateListItem,
} from '../../../../common';
export { serializeComponentTemplate } from '../../../../common/lib';

View file

@ -27,7 +27,7 @@ export const renderApp = (
const { i18n, docLinks, notifications } = core;
const { Context: I18nContext } = i18n;
const { services, history } = dependencies;
const { services, history, setBreadcrumbs } = dependencies;
const componentTemplateProviderValues = {
httpClient: services.httpService.httpClient,
@ -35,6 +35,7 @@ export const renderApp = (
trackMetric: services.uiMetricService.trackMetric.bind(services.uiMetricService),
docLinks,
toasts: notifications.toasts,
setBreadcrumbs,
};
render(

View file

@ -50,6 +50,7 @@ export async function mountManagementSection(
},
services,
history,
setBreadcrumbs,
};
return renderApp(element, { core, dependencies: appDependencies });

View file

@ -4,17 +4,12 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { i18n } from '@kbn/i18n';
import { schema } from '@kbn/config-schema';
import { serializeComponentTemplate } from '../../../../common/lib';
import { RouteDependencies } from '../../../types';
import { addBasePath } from '../index';
import { componentTemplateSchema } from './schema_validation';
const bodySchema = schema.object({
name: schema.string(),
...componentTemplateSchema,
});
export const registerCreateRoute = ({
router,
license,
@ -24,13 +19,15 @@ export const registerCreateRoute = ({
{
path: addBasePath('/component_templates'),
validate: {
body: bodySchema,
body: componentTemplateSchema,
},
},
license.guardApiRoute(async (ctx, req, res) => {
const { callAsCurrentUser } = ctx.dataManagement!.client;
const { name, ...componentTemplateDefinition } = req.body;
const serializedComponentTemplate = serializeComponentTemplate(req.body);
const { name } = req.body;
try {
// Check that a component template with the same name doesn't already exist
@ -60,7 +57,7 @@ export const registerCreateRoute = ({
try {
const response = await callAsCurrentUser('dataManagement.saveComponentTemplate', {
name,
body: componentTemplateDefinition,
body: serializedComponentTemplate,
});
return res.ok({ body: response });

View file

@ -5,7 +5,8 @@
*/
import { schema } from '@kbn/config-schema';
export const componentTemplateSchema = {
export const componentTemplateSchema = schema.object({
name: schema.string(),
template: schema.object({
settings: schema.maybe(schema.object({}, { unknowns: 'allow' })),
aliases: schema.maybe(schema.object({}, { unknowns: 'allow' })),
@ -13,4 +14,7 @@ export const componentTemplateSchema = {
}),
version: schema.maybe(schema.number()),
_meta: schema.maybe(schema.object({}, { unknowns: 'allow' })),
};
_kbnMeta: schema.object({
usedBy: schema.arrayOf(schema.string()),
}),
});

View file

@ -9,8 +9,6 @@ import { RouteDependencies } from '../../../types';
import { addBasePath } from '../index';
import { componentTemplateSchema } from './schema_validation';
const bodySchema = schema.object(componentTemplateSchema);
const paramsSchema = schema.object({
name: schema.string(),
});
@ -24,7 +22,7 @@ export const registerUpdateRoute = ({
{
path: addBasePath('/component_templates/{name}'),
validate: {
body: bodySchema,
body: componentTemplateSchema,
params: paramsSchema,
},
},

View file

@ -146,6 +146,9 @@ export default function ({ getService }: FtrProviderContext) {
id: 10,
},
},
_kbnMeta: {
usedBy: [],
},
})
.expect(200);
@ -162,6 +165,9 @@ export default function ({ getService }: FtrProviderContext) {
.send({
name: REQUIRED_FIELDS_COMPONENT_NAME,
template: {},
_kbnMeta: {
usedBy: [],
},
})
.expect(200);
@ -177,6 +183,9 @@ export default function ({ getService }: FtrProviderContext) {
.send({
name: COMPONENT_NAME,
template: {},
_kbnMeta: {
usedBy: [],
},
})
.expect(409);
@ -233,7 +242,11 @@ export default function ({ getService }: FtrProviderContext) {
.set('kbn-xsrf', 'xxx')
.send({
...COMPONENT,
name: COMPONENT_NAME,
version: 1,
_kbnMeta: {
usedBy: [],
},
})
.expect(200);
@ -250,7 +263,11 @@ export default function ({ getService }: FtrProviderContext) {
.set('kbn-xsrf', 'xxx')
.send({
...COMPONENT,
name: 'component_does_not_exist',
version: 1,
_kbnMeta: {
usedBy: [],
},
})
.expect(404);