[Component templates] Table view (#68031)

This commit is contained in:
Alison Goryachev 2020-06-09 14:24:47 -04:00 committed by GitHub
parent a3df86d627
commit ee5284e7fd
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
37 changed files with 1436 additions and 22 deletions

View file

@ -4,7 +4,7 @@
* you may not use this file except in compliance with the Elastic License.
*/
export { PLUGIN, API_BASE_PATH, CREATE_LEGACY_TEMPLATE_BY_DEFAULT } from './constants';
export { PLUGIN, API_BASE_PATH, CREATE_LEGACY_TEMPLATE_BY_DEFAULT, BASE_PATH } from './constants';
export { getTemplateParameter } from './lib';

View file

@ -0,0 +1,94 @@
/*
* 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 { deserializeComponentTemplate } 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',
},
},
},
},
},
},
[
{
name: 'my_index_template',
index_template: {
index_patterns: ['foo'],
template: {
settings: {
number_of_replicas: 2,
},
},
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',
},
created_at: {
type: 'date',
format: 'EEE MMM dd HH:mm:ss Z yyyy',
},
},
},
},
_kbnMeta: {
usedBy: ['my_index_template'],
},
});
});
});

View file

@ -0,0 +1,86 @@
/*
* 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 {
TemplateFromEs,
ComponentTemplateFromEs,
ComponentTemplateDeserialized,
ComponentTemplateListItem,
} from '../types';
const hasEntries = (data: object = {}) => Object.entries(data).length > 0;
/**
* Normalize a list of component templates to a map where each key
* is a component template name, and the value is an array of index templates name using it
*
* @example
*
{
"comp-1": [
"template-1",
"template-2"
],
"comp2": [
"template-1",
"template-2"
]
}
*
* @param indexTemplatesEs List of component templates
*/
const getIndexTemplatesToUsedBy = (indexTemplatesEs: TemplateFromEs[]) => {
return indexTemplatesEs.reduce((acc, item) => {
if (item.index_template.composed_of) {
item.index_template.composed_of.forEach((component) => {
acc[component] = acc[component] ? [...acc[component], item.name] : [item.name];
});
}
return acc;
}, {} as { [key: string]: string[] });
};
export function deserializeComponentTemplate(
componentTemplateEs: ComponentTemplateFromEs,
indexTemplatesEs: TemplateFromEs[]
) {
const { name, component_template: componentTemplate } = componentTemplateEs;
const { template, _meta, version } = componentTemplate;
const indexTemplatesToUsedBy = getIndexTemplatesToUsedBy(indexTemplatesEs);
const deserializedComponentTemplate: ComponentTemplateDeserialized = {
name,
template,
version,
_meta,
_kbnMeta: {
usedBy: indexTemplatesToUsedBy[name] || [],
},
};
return deserializedComponentTemplate;
}
export function deserializeComponenTemplateList(
componentTemplateEs: ComponentTemplateFromEs,
indexTemplatesEs: TemplateFromEs[]
) {
const { name, component_template: componentTemplate } = componentTemplateEs;
const { template } = componentTemplate;
const indexTemplatesToUsedBy = getIndexTemplatesToUsedBy(indexTemplatesEs);
const componentTemplateListItem: ComponentTemplateListItem = {
name,
usedBy: indexTemplatesToUsedBy[name] || [],
hasSettings: hasEntries(template.settings),
hasMappings: hasEntries(template.mappings),
hasAliases: hasEntries(template.aliases),
};
return componentTemplateListItem;
}

View file

@ -11,3 +11,8 @@ export {
} from './template_serialization';
export { getTemplateParameter } from './utils';
export {
deserializeComponentTemplate,
deserializeComponenTemplateList,
} from './component_template_serialization';

View file

@ -0,0 +1,39 @@
/*
* 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 { IndexSettings } from './indices';
import { Aliases } from './aliases';
import { Mappings } from './mappings';
export interface ComponentTemplateSerialized {
template: {
settings?: IndexSettings;
aliases?: Aliases;
mappings?: Mappings;
};
version?: number;
_meta?: { [key: string]: any };
}
export interface ComponentTemplateDeserialized extends ComponentTemplateSerialized {
name: string;
_kbnMeta: {
usedBy: string[];
};
}
export interface ComponentTemplateFromEs {
name: string;
component_template: ComponentTemplateSerialized;
}
export interface ComponentTemplateListItem {
name: string;
usedBy: string[];
hasMappings: boolean;
hasAliases: boolean;
hasSettings: boolean;
}

View file

@ -11,3 +11,5 @@ export * from './indices';
export * from './mappings';
export * from './templates';
export * from './component_templates';

View file

@ -49,6 +49,11 @@ export interface TemplateDeserialized {
};
}
export interface TemplateFromEs {
name: string;
index_template: TemplateSerialized;
}
/**
* Interface for the template list in our UI table
* we don't include the mappings, settings and aliases

View file

@ -5,10 +5,12 @@
*/
import React, { useEffect } from 'react';
import { Router, Switch, Route, Redirect } from 'react-router-dom';
import { ScopedHistory } from 'kibana/public';
import { UIM_APP_LOAD } from '../../common/constants';
import { IndexManagementHome } from './sections/home';
import { IndexManagementHome, homeSections } from './sections/home';
import { TemplateCreate } from './sections/template_create';
import { TemplateClone } from './sections/template_clone';
import { TemplateEdit } from './sections/template_edit';
@ -32,7 +34,7 @@ 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 path={`/:section(indices|templates)`} component={IndexManagementHome} />
<Route path={`/:section(${homeSections.join('|')})`} component={IndexManagementHome} />
<Redirect from={`/`} to={`/indices`} />
</Switch>
);

View file

@ -0,0 +1,174 @@
/*
* 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 { setupEnvironment, pageHelpers } from './helpers';
import { ComponentTemplateListTestBed } from './helpers/component_template_list.helpers';
import { API_BASE_PATH } from '../../../../../../common/constants';
import { ComponentTemplateListItem } from '../../types';
const { setup } = pageHelpers.componentTemplateList;
jest.mock('ui/i18n', () => {
const I18nContext = ({ children }: any) => children;
return { I18nContext };
});
describe('<ComponentTemplateList />', () => {
const { server, httpRequestsMockHelpers } = setupEnvironment();
let testBed: ComponentTemplateListTestBed;
afterAll(() => {
server.restore();
});
beforeEach(async () => {
await act(async () => {
testBed = await setup();
});
testBed.component.update();
});
describe('With component templates', () => {
const componentTemplate1: ComponentTemplateListItem = {
name: 'test_component_template_1',
hasMappings: true,
hasAliases: true,
hasSettings: true,
usedBy: [],
};
const componentTemplate2: ComponentTemplateListItem = {
name: 'test_component_template_2',
hasMappings: true,
hasAliases: true,
hasSettings: true,
usedBy: ['test_index_template_1'],
};
const componentTemplates = [componentTemplate1, componentTemplate2];
httpRequestsMockHelpers.setLoadComponentTemplatesResponse(componentTemplates);
test('should render the list view', async () => {
const { table } = testBed;
// Verify table content
const { tableCellsValues } = table.getMetaData('componentTemplatesTable');
tableCellsValues.forEach((row, i) => {
const { name, usedBy } = componentTemplates[i];
const usedByText = usedBy.length === 0 ? 'Not in use' : usedBy.length.toString();
expect(row).toEqual(['', name, usedByText, '', '', '', '']);
});
});
test('should reload the component templates data', async () => {
const { component, actions } = testBed;
const totalRequests = server.requests.length;
await act(async () => {
actions.clickReloadButton();
});
component.update();
expect(server.requests.length).toBe(totalRequests + 1);
expect(server.requests[server.requests.length - 1].url).toBe(
`${API_BASE_PATH}/component_templates`
);
});
test('should delete a component template', async () => {
const { actions, component } = testBed;
const { name: componentTemplateName } = componentTemplate1;
await act(async () => {
actions.clickDeleteActionAt(0);
});
// We need to read the document "body" as the modal is added there and not inside
// the component DOM tree.
const modal = document.body.querySelector(
'[data-test-subj="deleteComponentTemplatesConfirmation"]'
);
const confirmButton: HTMLButtonElement | null = modal!.querySelector(
'[data-test-subj="confirmModalConfirmButton"]'
);
expect(modal).not.toBe(null);
expect(modal!.textContent).toContain('Delete component template');
httpRequestsMockHelpers.setDeleteComponentTemplateResponse({
itemsDeleted: [componentTemplateName],
errors: [],
});
await act(async () => {
confirmButton!.click();
});
component.update();
const deleteRequest = server.requests[server.requests.length - 2];
expect(deleteRequest.method).toBe('DELETE');
expect(deleteRequest.url).toBe(
`${API_BASE_PATH}/component_templates/${componentTemplateName}`
);
expect(deleteRequest.status).toEqual(200);
});
});
describe('No component templates', () => {
beforeEach(async () => {
httpRequestsMockHelpers.setLoadComponentTemplatesResponse([]);
await act(async () => {
testBed = await setup();
});
testBed.component.update();
});
test('should display an empty prompt', async () => {
const { exists, find } = testBed;
expect(exists('sectionLoading')).toBe(false);
expect(exists('emptyList')).toBe(true);
expect(find('emptyList.title').text()).toEqual('Start by creating a component template');
});
});
describe('Error handling', () => {
beforeEach(async () => {
const error = {
status: 500,
error: 'Internal server error',
message: 'Internal server error',
};
httpRequestsMockHelpers.setLoadComponentTemplatesResponse(undefined, { body: error });
await act(async () => {
testBed = await setup();
});
testBed.component.update();
});
test('should render an error message if error fetching component templates', async () => {
const { exists, find } = testBed;
expect(exists('componentTemplatesLoadError')).toBe(true);
expect(find('componentTemplatesLoadError').text()).toContain(
'Unable to load component templates. Try again.'
);
});
});
});

View file

@ -0,0 +1,95 @@
/*
* 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 { BASE_PATH } from '../../../../../../../common';
import {
registerTestBed,
TestBed,
TestBedConfig,
findTestSubject,
nextTick,
} from '../../../../../../../../../test_utils';
import { WithAppDependencies } from './setup_environment';
import { ComponentTemplateList } from '../../../component_template_list';
const testBedConfig: TestBedConfig = {
memoryRouter: {
initialEntries: [`${BASE_PATH}component_templates`],
componentRoutePath: `${BASE_PATH}component_templates`,
},
doMountAsync: true,
};
const initTestBed = registerTestBed(WithAppDependencies(ComponentTemplateList), testBedConfig);
export type ComponentTemplateListTestBed = TestBed<ComponentTemplateTestSubjects> & {
actions: ReturnType<typeof createActions>;
};
const createActions = (testBed: TestBed) => {
const { find } = testBed;
/**
* User Actions
*/
const clickReloadButton = () => {
find('reloadButton').simulate('click');
};
const clickComponentTemplateAt = async (index: number) => {
const { component, table, router } = testBed;
const { rows } = table.getMetaData('componentTemplatesTable');
const componentTemplateLink = findTestSubject(
rows[index].reactWrapper,
'componentTemplateDetailsLink'
);
await act(async () => {
const { href } = componentTemplateLink.props();
router.navigateTo(href!);
await nextTick();
component.update();
});
};
const clickDeleteActionAt = (index: number) => {
const { table } = testBed;
const { rows } = table.getMetaData('componentTemplatesTable');
const deleteButton = findTestSubject(rows[index].reactWrapper, 'deleteComponentTemplateButton');
deleteButton.simulate('click');
};
return {
clickReloadButton,
clickComponentTemplateAt,
clickDeleteActionAt,
};
};
export const setup = async (): Promise<ComponentTemplateListTestBed> => {
const testBed = await initTestBed();
return {
...testBed,
actions: createActions(testBed),
};
};
export type ComponentTemplateTestSubjects =
| 'componentTemplatesTable'
| 'componentTemplateDetails'
| 'componentTemplateDetails.title'
| 'deleteComponentTemplatesConfirmation'
| 'emptyList'
| 'emptyList.title'
| 'sectionLoading'
| 'componentTemplatesLoadError'
| 'deleteComponentTemplateButton'
| 'reloadButton';

View file

@ -0,0 +1,52 @@
/*
* 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 sinon, { SinonFakeServer } from 'sinon';
import { API_BASE_PATH } from '../../../../../../../common';
// Register helpers to mock HTTP Requests
const registerHttpRequestMockHelpers = (server: SinonFakeServer) => {
const setLoadComponentTemplatesResponse = (response?: any[], error?: any) => {
const status = error ? error.status || 400 : 200;
const body = error ? error.body : response;
server.respondWith('GET', `${API_BASE_PATH}/component_templates`, [
status,
{ 'Content-Type': 'application/json' },
JSON.stringify(body),
]);
};
const setDeleteComponentTemplateResponse = (response?: object) => {
server.respondWith('DELETE', `${API_BASE_PATH}/component_templates/:name`, [
200,
{ 'Content-Type': 'application/json' },
JSON.stringify(response),
]);
};
return {
setLoadComponentTemplatesResponse,
setDeleteComponentTemplateResponse,
};
};
export const init = () => {
const server = sinon.fakeServer.create();
server.respondImmediately = true;
// Define default response for unhandled requests.
// We make requests to APIs which don't impact the component under test, e.g. UI metric telemetry,
// and we can mock them all with a 200 instead of mocking each one individually.
server.respondWith([200, {}, 'DefaultMockedResponse']);
const httpRequestsMockHelpers = registerHttpRequestMockHelpers(server);
return {
server,
httpRequestsMockHelpers,
};
};

View file

@ -0,0 +1,15 @@
/*
* 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 { setup as componentTemplatesListSetup } from './component_template_list.helpers';
export { nextTick, getRandomString, findTestSubject } from '../../../../../../../../../test_utils';
export { setupEnvironment } from './setup_environment';
export const pageHelpers = {
componentTemplateList: { setup: componentTemplatesListSetup },
};

View file

@ -0,0 +1,45 @@
/*
* 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.
*/
/* eslint-disable @kbn/eslint/no-restricted-paths */
import React from 'react';
import axios from 'axios';
import axiosXhrAdapter from 'axios/lib/adapters/xhr';
import { HttpSetup } from 'kibana/public';
import { BASE_PATH, API_BASE_PATH } from '../../../../../../../common/constants';
import {
notificationServiceMock,
docLinksServiceMock,
} from '../../../../../../../../../../src/core/public/mocks';
import { init as initHttpRequests } from './http_requests';
import { ComponentTemplatesProvider } from '../../../component_templates_context';
const mockHttpClient = axios.create({ adapter: axiosXhrAdapter });
const appDependencies = {
httpClient: (mockHttpClient as unknown) as HttpSetup,
apiBasePath: API_BASE_PATH,
appBasePath: BASE_PATH,
trackMetric: () => {},
docLinks: docLinksServiceMock.createStartContract(),
toasts: notificationServiceMock.createSetupContract().toasts,
};
export const setupEnvironment = () => {
const { server, httpRequestsMockHelpers } = initHttpRequests();
return {
server,
httpRequestsMockHelpers,
};
};
export const WithAppDependencies = (Comp: any) => (props: any) => (
<ComponentTemplatesProvider value={appDependencies}>
<Comp {...props} />
</ComponentTemplatesProvider>
);

View file

@ -0,0 +1,75 @@
/*
* 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 { FormattedMessage } from '@kbn/i18n/react';
import { SectionLoading } from '../shared_imports';
import { useComponentTemplatesContext } from '../component_templates_context';
import { UIM_COMPONENT_TEMPLATE_LIST_LOAD } from '../constants';
import { EmptyPrompt } from './empty_prompt';
import { ComponentTable } from './table';
import { LoadError } from './error';
import { ComponentTemplatesDeleteModal } from './delete_modal';
export const ComponentTemplateList: React.FunctionComponent = () => {
const { api, trackMetric } = useComponentTemplatesContext();
const { data, isLoading, error, sendRequest } = api.useLoadComponentTemplates();
const [componentTemplatesToDelete, setComponentTemplatesToDelete] = useState<string[]>([]);
// Track component loaded
useEffect(() => {
trackMetric('loaded', UIM_COMPONENT_TEMPLATE_LIST_LOAD);
}, [trackMetric]);
if (data && data.length === 0) {
return <EmptyPrompt />;
}
let content: React.ReactNode;
if (isLoading) {
content = (
<SectionLoading data-test-subj="sectionLoading">
<FormattedMessage
id="xpack.idxMgmt.home.componentTemplates.list.loadingMessage"
defaultMessage="Loading component templates..."
/>
</SectionLoading>
);
} else if (data?.length) {
content = (
<ComponentTable
componentTemplates={data}
onReloadClick={sendRequest}
onDeleteClick={setComponentTemplatesToDelete}
/>
);
} else if (error) {
content = <LoadError onReloadClick={sendRequest} />;
}
return (
<div data-test-subj="componentTemplateList">
{content}
{componentTemplatesToDelete?.length > 0 ? (
<ComponentTemplatesDeleteModal
callback={(deleteResponse) => {
if (deleteResponse?.hasDeletedComponentTemplates) {
// refetch the component templates
sendRequest();
}
setComponentTemplatesToDelete([]);
}}
componentTemplatesToDelete={componentTemplatesToDelete}
/>
) : null}
</div>
);
};

View file

@ -0,0 +1,128 @@
/*
* 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 { EuiConfirmModal, EuiOverlayMask } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n/react';
import { useComponentTemplatesContext } from '../component_templates_context';
export const ComponentTemplatesDeleteModal = ({
componentTemplatesToDelete,
callback,
}: {
componentTemplatesToDelete: string[];
callback: (data?: { hasDeletedComponentTemplates: boolean }) => void;
}) => {
const { toasts, api } = useComponentTemplatesContext();
const numComponentTemplatesToDelete = componentTemplatesToDelete.length;
const handleDeleteComponentTemplates = () => {
api
.deleteComponentTemplates(componentTemplatesToDelete)
.then(({ data: { itemsDeleted, errors }, error }) => {
const hasDeletedComponentTemplates = itemsDeleted && itemsDeleted.length;
if (hasDeletedComponentTemplates) {
const successMessage =
itemsDeleted.length === 1
? i18n.translate(
'xpack.idxMgmt.home.componentTemplates.deleteModal.successDeleteSingleNotificationMessageText',
{
defaultMessage: "Deleted component template '{componentTemplateName}'",
values: { componentTemplateName: componentTemplatesToDelete[0] },
}
)
: i18n.translate(
'xpack.idxMgmt.home.componentTemplates.deleteModal.successDeleteMultipleNotificationMessageText',
{
defaultMessage:
'Deleted {numSuccesses, plural, one {# component template} other {# component templates}}',
values: { numSuccesses: itemsDeleted.length },
}
);
callback({ hasDeletedComponentTemplates });
toasts.addSuccess(successMessage);
}
if (error || errors?.length) {
const hasMultipleErrors =
errors?.length > 1 || (error && componentTemplatesToDelete.length > 1);
const errorMessage = hasMultipleErrors
? i18n.translate(
'xpack.idxMgmt.home.componentTemplates.deleteModal.multipleErrorsNotificationMessageText',
{
defaultMessage: 'Error deleting {count} component templates',
values: {
count: errors?.length || componentTemplatesToDelete.length,
},
}
)
: i18n.translate(
'xpack.idxMgmt.home.componentTemplates.deleteModal.errorNotificationMessageText',
{
defaultMessage: "Error deleting component template '{name}'",
values: { name: (errors && errors[0].name) || componentTemplatesToDelete[0] },
}
);
toasts.addDanger(errorMessage);
}
});
};
const handleOnCancel = () => {
callback();
};
return (
<EuiOverlayMask>
<EuiConfirmModal
buttonColor="danger"
data-test-subj="deleteComponentTemplatesConfirmation"
title={
<FormattedMessage
id="xpack.idxMgmt.home.componentTemplates.deleteModal.modalTitleText"
defaultMessage="Delete {numComponentTemplatesToDelete, plural, one {component template} other {# component templates}}"
values={{ numComponentTemplatesToDelete }}
/>
}
onCancel={handleOnCancel}
onConfirm={handleDeleteComponentTemplates}
cancelButtonText={
<FormattedMessage
id="xpack.idxMgmt.home.componentTemplates.deleteModal.cancelButtonLabel"
defaultMessage="Cancel"
/>
}
confirmButtonText={
<FormattedMessage
id="xpack.idxMgmt.home.componentTemplates.confirmButtonLabel"
defaultMessage="Delete {numComponentTemplatesToDelete, plural, one {component template} other {component templates} }"
values={{ numComponentTemplatesToDelete }}
/>
}
>
<>
<p>
<FormattedMessage
id="xpack.idxMgmt.home.componentTemplates.deleteModal.deleteDescription"
defaultMessage="You are about to delete {numComponentTemplatesToDelete, plural, one {this component template} other {these component templates} }:"
values={{ numComponentTemplatesToDelete }}
/>
</p>
<ul>
{componentTemplatesToDelete.map((name) => (
<li key={name}>{name}</li>
))}
</ul>
</>
</EuiConfirmModal>
</EuiOverlayMask>
);
};

View file

@ -0,0 +1,43 @@
/*
* 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 } from 'react';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n/react';
import { EuiEmptyPrompt, EuiLink } from '@elastic/eui';
import { useComponentTemplatesContext } from '../component_templates_context';
export const EmptyPrompt: FunctionComponent = () => {
const { documentation } = useComponentTemplatesContext();
return (
<EuiEmptyPrompt
iconType="managementApp"
data-test-subj="emptyList"
title={
<h2 data-test-subj="title">
{i18n.translate('xpack.idxMgmt.home.componentTemplates.emptyPromptTitle', {
defaultMessage: 'Start by creating a component template',
})}
</h2>
}
body={
<p>
<FormattedMessage
id="xpack.idxMgmt.home.componentTemplates.emptyPromptDescription"
defaultMessage="For example, you might create a component template that defines index settings that can be reused across index templates."
/>
<br />
<EuiLink href={documentation.componentTemplates} target="_blank" external>
{i18n.translate('xpack.idxMgmt.home.componentTemplates.emptyPromptDocumentionLink', {
defaultMessage: 'Learn more',
})}
</EuiLink>
</p>
}
/>
);
};

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 React, { FunctionComponent } from 'react';
import { FormattedMessage } from '@kbn/i18n/react';
import { EuiLink, EuiCallOut } from '@elastic/eui';
export interface Props {
onReloadClick: () => void;
}
export const LoadError: FunctionComponent<Props> = ({ onReloadClick }) => {
return (
<EuiCallOut
iconType="faceSad"
color="danger"
data-test-subj="componentTemplatesLoadError"
title={
<FormattedMessage
id="xpack.idxMgmt.home.componentTemplates.list.loadErrorTitle"
defaultMessage="Unable to load component templates. {reloadLink}"
values={{
reloadLink: (
<EuiLink onClick={onReloadClick}>
<FormattedMessage
id="xpack.idxMgmt.home.componentTemplates.list.loadErrorReloadLinkLabel"
defaultMessage="Try again."
/>
</EuiLink>
),
}}
/>
}
/>
);
};

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 { ComponentTemplateList } from './component_template_list';

View file

@ -0,0 +1,205 @@
/*
* 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, useState } from 'react';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n/react';
import {
EuiInMemoryTable,
EuiButton,
EuiInMemoryTableProps,
EuiTextColor,
EuiIcon,
} from '@elastic/eui';
import { ComponentTemplateListItem } from '../types';
export interface Props {
componentTemplates: ComponentTemplateListItem[];
onReloadClick: () => void;
onDeleteClick: (componentTemplateName: string[]) => void;
}
export const ComponentTable: FunctionComponent<Props> = ({
componentTemplates,
onReloadClick,
onDeleteClick,
}) => {
const [selection, setSelection] = useState<ComponentTemplateListItem[]>([]);
const tableProps: EuiInMemoryTableProps<ComponentTemplateListItem> = {
itemId: 'name',
isSelectable: true,
'data-test-subj': 'componentTemplatesTable',
sorting: { sort: { field: 'name', direction: 'asc' } },
selection: {
onSelectionChange: setSelection,
selectable: ({ usedBy }) => usedBy.length === 0,
selectableMessage: (selectable) =>
selectable
? i18n.translate('xpack.idxMgmt.componentTemplatesList.table.selectionLabel', {
defaultMessage: 'Select this component template',
})
: i18n.translate('xpack.idxMgmt.componentTemplatesList.table.disabledSelectionLabel', {
defaultMessage: 'Component template is in use and cannot be deleted',
}),
},
rowProps: () => ({
'data-test-subj': 'componentTemplateTableRow',
}),
search: {
toolsLeft:
selection.length > 0 ? (
<EuiButton
data-test-subj="deleteComponentTemplatexButton"
onClick={() => onDeleteClick(selection.map(({ name }) => name))}
color="danger"
>
<FormattedMessage
id="xpack.idxMgmt.componentTemplatesList.table.deleteComponentTemplatesButtonLabel"
defaultMessage="Delete {count, plural, one {component template} other {component templates} }"
values={{ count: selection.length }}
/>
</EuiButton>
) : undefined,
toolsRight: [
<EuiButton
key="reloadButton"
iconType="refresh"
color="secondary"
data-test-subj="reloadButton"
onClick={onReloadClick}
>
{i18n.translate('xpack.idxMgmt.componentTemplatesList.table.reloadButtonLabel', {
defaultMessage: 'Reload',
})}
</EuiButton>,
],
box: {
incremental: true,
},
filters: [
{
type: 'field_value_toggle_group',
field: 'usedBy.length',
items: [
{
value: 1,
name: i18n.translate(
'xpack.idxMgmt.componentTemplatesList.table.inUseFilterOptionLabel',
{
defaultMessage: 'In use',
}
),
operator: 'gte',
},
{
value: 0,
name: i18n.translate(
'xpack.idxMgmt.componentTemplatesList.table.notInUseFilterOptionLabel',
{
defaultMessage: 'Not in use',
}
),
operator: 'eq',
},
],
},
],
},
pagination: {
initialPageSize: 10,
pageSizeOptions: [10, 20, 50],
},
columns: [
{
field: 'name',
name: i18n.translate('xpack.idxMgmt.componentTemplatesList.table.nameColumnTitle', {
defaultMessage: 'Name',
}),
sortable: true,
},
{
field: 'usedBy',
name: i18n.translate('xpack.idxMgmt.componentTemplatesList.table.isInUseColumnTitle', {
defaultMessage: 'Index templates',
}),
sortable: true,
render: (usedBy: string[]) => {
if (usedBy.length) {
return usedBy.length;
}
return (
<EuiTextColor color="subdued">
<i>
<FormattedMessage
id="xpack.idxMgmt.componentTemplatesList.table.notInUseCellDescription"
defaultMessage="Not in use"
/>
</i>
</EuiTextColor>
);
},
},
{
field: 'hasMappings',
name: i18n.translate('xpack.idxMgmt.componentTemplatesList.table.mappingsColumnTitle', {
defaultMessage: 'Mappings',
}),
truncateText: true,
sortable: true,
render: (hasMappings: boolean) => (hasMappings ? <EuiIcon type="check" /> : null),
},
{
field: 'hasSettings',
name: i18n.translate('xpack.idxMgmt.componentTemplatesList.table.settingsColumnTitle', {
defaultMessage: 'Settings',
}),
truncateText: true,
sortable: true,
render: (hasSettings: boolean) => (hasSettings ? <EuiIcon type="check" /> : null),
},
{
field: 'hasAliases',
name: i18n.translate('xpack.idxMgmt.componentTemplatesList.table.aliasesColumnTitle', {
defaultMessage: 'Aliases',
}),
truncateText: true,
sortable: true,
render: (hasAliases: boolean) => (hasAliases ? <EuiIcon type="check" /> : null),
},
{
name: (
<FormattedMessage
id="xpack.idxMgmt.componentTemplatesList.table.actionColumnTitle"
defaultMessage="Actions"
/>
),
actions: [
{
'data-test-subj': 'deleteComponentTemplateButton',
isPrimary: true,
name: i18n.translate('xpack.idxMgmt.componentTemplatesList.table.deleteActionLabel', {
defaultMessage: 'Delete',
}),
description: i18n.translate(
'xpack.idxMgmt.componentTemplatesList.table.deleteActionDescription',
{ defaultMessage: 'Delete this component template' }
),
type: 'icon',
icon: 'trash',
color: 'danger',
onClick: ({ name }) => onDeleteClick([name]),
enabled: ({ usedBy }) => usedBy.length === 0,
},
],
},
],
items: componentTemplates ?? [],
};
return <EuiInMemoryTable {...tableProps} />;
};

View file

@ -0,0 +1,63 @@
/*
* 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, { createContext, useContext } from 'react';
import { HttpSetup, DocLinksSetup, NotificationsSetup } from 'src/core/public';
import { getApi, getUseRequest, getSendRequest, getDocumentation } from './lib';
const ComponentTemplatesContext = createContext<Context | undefined>(undefined);
interface Props {
httpClient: HttpSetup;
apiBasePath: string;
appBasePath: string;
trackMetric: (type: 'loaded' | 'click' | 'count', eventName: string) => void;
docLinks: DocLinksSetup;
toasts: NotificationsSetup['toasts'];
}
interface Context {
api: ReturnType<typeof getApi>;
documentation: ReturnType<typeof getDocumentation>;
trackMetric: (type: 'loaded' | 'click' | 'count', eventName: string) => void;
toasts: NotificationsSetup['toasts'];
appBasePath: string;
}
export const ComponentTemplatesProvider = ({
children,
value,
}: {
value: Props;
children: React.ReactNode;
}) => {
const { httpClient, apiBasePath, trackMetric, docLinks, toasts, appBasePath } = value;
const useRequest = getUseRequest(httpClient);
const sendRequest = getSendRequest(httpClient);
const api = getApi(useRequest, sendRequest, apiBasePath, trackMetric);
const documentation = getDocumentation(docLinks);
return (
<ComponentTemplatesContext.Provider
value={{ api, documentation, trackMetric, toasts, appBasePath }}
>
{children}
</ComponentTemplatesContext.Provider>
);
};
export const useComponentTemplatesContext = () => {
const ctx = useContext(ComponentTemplatesContext);
if (!ctx) {
throw new Error(
'"useComponentTemplatesContext" can only be called inside of ComponentTemplatesProvider!'
);
}
return ctx;
};

View file

@ -0,0 +1,10 @@
/*
* 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.
*/
// ui metric constants
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';

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 { ComponentTemplatesProvider } from './component_templates_context';
export { ComponentTemplateList } from './component_template_list';
export * from './types';

View file

@ -0,0 +1,44 @@
/*
* 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 { ComponentTemplateListItem } from '../types';
import { UseRequestHook, SendRequestHook } from './request';
import { UIM_COMPONENT_TEMPLATE_DELETE_MANY, UIM_COMPONENT_TEMPLATE_DELETE } from '../constants';
export const getApi = (
useRequest: UseRequestHook,
sendRequest: SendRequestHook,
apiBasePath: string,
trackMetric: (type: 'loaded' | 'click' | 'count', eventName: string) => void
) => {
function useLoadComponentTemplates() {
return useRequest<ComponentTemplateListItem[]>({
path: `${apiBasePath}/component_templates`,
method: 'get',
});
}
function deleteComponentTemplates(names: string[]) {
const result = sendRequest({
path: `${apiBasePath}/component_templates/${names
.map((name) => encodeURIComponent(name))
.join(',')}`,
method: 'delete',
});
trackMetric(
'count',
names.length > 1 ? UIM_COMPONENT_TEMPLATE_DELETE_MANY : UIM_COMPONENT_TEMPLATE_DELETE
);
return result;
}
return {
useLoadComponentTemplates,
deleteComponentTemplates,
};
};

View file

@ -0,0 +1,16 @@
/*
* 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 { DocLinksSetup } from 'src/core/public';
export const getDocumentation = ({ ELASTIC_WEBSITE_URL, DOC_LINK_VERSION }: DocLinksSetup) => {
const docsBase = `${ELASTIC_WEBSITE_URL}guide/en`;
const esDocsBase = `${docsBase}/elasticsearch/reference/${DOC_LINK_VERSION}`;
return {
componentTemplates: `${esDocsBase}/indices-component-template.html`,
};
};

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 * from './api';
export * from './request';
export * from './documentation';

View file

@ -0,0 +1,31 @@
/*
* 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 { HttpSetup } from 'src/core/public';
import {
UseRequestConfig,
UseRequestResponse,
SendRequestConfig,
SendRequestResponse,
sendRequest as _sendRequest,
useRequest as _useRequest,
} from '../shared_imports';
export type UseRequestHook = <T = any>(config: UseRequestConfig) => UseRequestResponse<T>;
export type SendRequestHook = (config: SendRequestConfig) => Promise<SendRequestResponse>;
export const getUseRequest = (httpClient: HttpSetup): UseRequestHook => <T = any>(
config: UseRequestConfig
) => {
return _useRequest<T>(httpClient, config);
};
export const getSendRequest = (httpClient: HttpSetup): SendRequestHook => <T = any>(
config: SendRequestConfig
) => {
return _sendRequest<T>(httpClient, config);
};

View file

@ -0,0 +1,15 @@
/*
* 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 {
UseRequestConfig,
UseRequestResponse,
SendRequestConfig,
SendRequestResponse,
sendRequest,
useRequest,
SectionLoading,
} from '../../../../../../../src/plugins/es_ui_shared/public';

View file

@ -0,0 +1,17 @@
/*
* 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.
*/
// Ideally, we shouldn't depend on anything in index management that is
// outside of the components_templates directory
// We could consider creating shared types or duplicating the types here if
// the component_templates app were to move outside of index management
import {
ComponentTemplateSerialized,
ComponentTemplateDeserialized,
ComponentTemplateListItem,
} from '../../../../common';
export { ComponentTemplateSerialized, ComponentTemplateDeserialized, ComponentTemplateListItem };

View file

@ -11,3 +11,4 @@ export { PageErrorForbidden } from './page_error';
export { TemplateDeleteModal } from './template_delete_modal';
export { TemplateForm } from './template_form';
export * from './mappings_editor';
export * from './component_templates';

View file

@ -10,9 +10,12 @@ import { render, unmountComponentAtNode } from 'react-dom';
import { CoreStart } from '../../../../../src/core/public';
import { API_BASE_PATH, BASE_PATH } from '../../common';
import { AppContextProvider, AppDependencies } from './app_context';
import { App } from './app';
import { indexManagementStore } from './store';
import { ComponentTemplatesProvider } from './components';
export const renderApp = (
elem: HTMLElement | null,
@ -22,15 +25,26 @@ export const renderApp = (
return () => undefined;
}
const { i18n } = core;
const { i18n, docLinks, notifications } = core;
const { Context: I18nContext } = i18n;
const { services, history } = dependencies;
const componentTemplateProviderValues = {
httpClient: services.httpService.httpClient,
apiBasePath: API_BASE_PATH,
appBasePath: BASE_PATH,
trackMetric: services.uiMetricService.trackMetric.bind(services.uiMetricService),
docLinks,
toasts: notifications.toasts,
};
render(
<I18nContext>
<Provider store={indexManagementStore(services)}>
<AppContextProvider value={dependencies}>
<App history={history} />
<ComponentTemplatesProvider value={componentTemplateProviderValues}>
<App history={history} />
</ComponentTemplatesProvider>
</AppContextProvider>
</Provider>
</I18nContext>,

View file

@ -21,9 +21,16 @@ import {
import { documentationService } from '../../services/documentation';
import { IndexList } from './index_list';
import { TemplateList } from './template_list';
import { ComponentTemplateList } from '../../components/component_templates';
import { breadcrumbService } from '../../services/breadcrumbs';
type Section = 'indices' | 'templates';
export enum Section {
Indices = 'indices',
IndexTemplates = 'templates',
ComponentTemplates = 'component_templates',
}
export const homeSections = [Section.Indices, Section.IndexTemplates, Section.ComponentTemplates];
interface MatchParams {
section: Section;
@ -37,11 +44,11 @@ export const IndexManagementHome: React.FunctionComponent<RouteComponentProps<Ma
}) => {
const tabs = [
{
id: 'indices' as Section,
id: Section.Indices,
name: <FormattedMessage id="xpack.idxMgmt.home.indicesTabTitle" defaultMessage="Indices" />,
},
{
id: 'templates' as Section,
id: Section.IndexTemplates,
name: (
<FormattedMessage
id="xpack.idxMgmt.home.indexTemplatesTabTitle"
@ -49,6 +56,15 @@ export const IndexManagementHome: React.FunctionComponent<RouteComponentProps<Ma
/>
),
},
{
id: Section.ComponentTemplates,
name: (
<FormattedMessage
id="xpack.idxMgmt.home.componentTemplatesTabTitle"
defaultMessage="Component Templates"
/>
),
},
];
const onSectionChange = (newSection: Section) => {
@ -106,13 +122,14 @@ export const IndexManagementHome: React.FunctionComponent<RouteComponentProps<Ma
<EuiSpacer size="m" />
<Switch>
<Route exact path="/indices" component={IndexList} />
<Route exact path="/indices/filter/:filter?" component={IndexList} />
<Route exact path={`/${Section.Indices}`} component={IndexList} />
<Route exact path={`/${Section.Indices}/filter/:filter?`} component={IndexList} />
<Route
exact
path={['/templates', '/templates/:templateName?']}
path={[`/${Section.IndexTemplates}`, `/${Section.IndexTemplates}/:templateName?`]}
component={TemplateList}
/>
<Route exact path={`/${Section.ComponentTemplates}`} component={ComponentTemplateList} />
</Switch>
</EuiPageContent>
</EuiPageBody>

View file

@ -4,4 +4,4 @@
* you may not use this file except in compliance with the Elastic License.
*/
export { IndexManagementHome } from './home';
export { IndexManagementHome, Section, homeSections } from './home';

View file

@ -10,6 +10,7 @@ export const elasticsearchJsPlugin = (Client: any, config: any, components: any)
Client.prototype.dataManagement = components.clientAction.namespaceFactory();
const dataManagement = Client.prototype.dataManagement.prototype;
// Component templates
dataManagement.getComponentTemplates = ca({
urls: [
{
@ -60,4 +61,14 @@ export const elasticsearchJsPlugin = (Client: any, config: any, components: any)
],
method: 'DELETE',
});
// Composable index templates
dataManagement.getComposableIndexTemplates = ca({
urls: [
{
fmt: '/_index_template',
},
],
method: 'GET',
});
};

View file

@ -5,6 +5,11 @@
*/
import { schema } from '@kbn/config-schema';
import {
deserializeComponentTemplate,
deserializeComponenTemplateList,
} from '../../../../common/lib';
import { ComponentTemplateFromEs } from '../../../../common';
import { RouteDependencies } from '../../../types';
import { addBasePath } from '../index';
@ -20,9 +25,25 @@ export function registerGetAllRoute({ router, license, lib: { isEsError } }: Rou
const { callAsCurrentUser } = ctx.dataManagement!.client;
try {
const response = await callAsCurrentUser('dataManagement.getComponentTemplates');
const {
component_templates: componentTemplates,
}: { component_templates: ComponentTemplateFromEs[] } = await callAsCurrentUser(
'dataManagement.getComponentTemplates'
);
return res.ok({ body: response.component_templates });
const { index_templates: indexTemplates } = await callAsCurrentUser(
'dataManagement.getComposableIndexTemplates'
);
const body = componentTemplates.map((componentTemplate) => {
const deserializedComponentTemplateListItem = deserializeComponenTemplateList(
componentTemplate,
indexTemplates
);
return deserializedComponentTemplateListItem;
});
return res.ok({ body });
} catch (error) {
if (isEsError(error)) {
return res.customError({
@ -56,11 +77,12 @@ export function registerGetAllRoute({ router, license, lib: { isEsError } }: Rou
}
);
const { index_templates: indexTemplates } = await callAsCurrentUser(
'dataManagement.getComposableIndexTemplates'
);
return res.ok({
body: {
...componentTemplates[0],
name,
},
body: deserializeComponentTemplate(componentTemplates[0], indexTemplates),
});
} catch (error) {
if (isEsError(error)) {

View file

@ -61,7 +61,10 @@ export default function ({ getService }: FtrProviderContext) {
expect(testComponentTemplate).to.eql({
name: COMPONENT_NAME,
component_template: COMPONENT,
usedBy: [],
hasSettings: true,
hasMappings: true,
hasAliases: false,
});
});
});
@ -74,8 +77,9 @@ export default function ({ getService }: FtrProviderContext) {
expect(body).to.eql({
name: COMPONENT_NAME,
component_template: {
...COMPONENT,
...COMPONENT,
_kbnMeta: {
usedBy: [],
},
});
});

View file

@ -47,5 +47,22 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => {
expect(templateList).to.be(true);
});
});
describe('Component templates', () => {
it('renders the component templates tab', async () => {
// Navigate to the component templates tab
await pageObjects.indexManagement.changeTabs('component_templatesTab');
await pageObjects.header.waitUntilLoadingHasFinished();
// Verify url
const url = await browser.getCurrentUrl();
expect(url).to.contain(`/component_templates`);
// There should be no component templates by default, so we verify the empty prompt displays
const componentTemplateEmptyPrompt = await testSubjects.exists('emptyList');
expect(componentTemplateEmptyPrompt).to.be(true);
});
});
});
};

View file

@ -44,7 +44,7 @@ export function IndexManagementPageProvider({ getService }: FtrProviderContext)
};
});
},
async changeTabs(tab: 'indicesTab' | 'templatesTab') {
async changeTabs(tab: 'indicesTab' | 'templatesTab' | 'component_templatesTab') {
await testSubjects.click(tab);
},
};