[Component templates] Table view (#68031)
This commit is contained in:
parent
a3df86d627
commit
ee5284e7fd
|
@ -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';
|
||||
|
||||
|
|
|
@ -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'],
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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;
|
||||
}
|
|
@ -11,3 +11,8 @@ export {
|
|||
} from './template_serialization';
|
||||
|
||||
export { getTemplateParameter } from './utils';
|
||||
|
||||
export {
|
||||
deserializeComponentTemplate,
|
||||
deserializeComponenTemplateList,
|
||||
} from './component_template_serialization';
|
||||
|
|
|
@ -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;
|
||||
}
|
|
@ -11,3 +11,5 @@ export * from './indices';
|
|||
export * from './mappings';
|
||||
|
||||
export * from './templates';
|
||||
|
||||
export * from './component_templates';
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -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.'
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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';
|
|
@ -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,
|
||||
};
|
||||
};
|
|
@ -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 },
|
||||
};
|
|
@ -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>
|
||||
);
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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>
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
|
@ -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>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
|
@ -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';
|
|
@ -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} />;
|
||||
};
|
|
@ -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;
|
||||
};
|
|
@ -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';
|
|
@ -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';
|
|
@ -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,
|
||||
};
|
||||
};
|
|
@ -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`,
|
||||
};
|
||||
};
|
|
@ -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';
|
|
@ -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);
|
||||
};
|
|
@ -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';
|
|
@ -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 };
|
|
@ -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';
|
||||
|
|
|
@ -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>,
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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',
|
||||
});
|
||||
};
|
||||
|
|
|
@ -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)) {
|
||||
|
|
|
@ -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: [],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
};
|
||||
|
|
|
@ -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);
|
||||
},
|
||||
};
|
||||
|
|
Loading…
Reference in a new issue