[Component template] Details flyout (#68732)

This commit is contained in:
Alison Goryachev 2020-06-16 21:48:28 -04:00 committed by GitHub
parent ca9a1626a2
commit a34a3a7e09
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
29 changed files with 1044 additions and 41 deletions

View file

@ -0,0 +1,230 @@
/*
* 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 { ComponentTemplateDetailsTestBed } from './helpers/component_template_details.helpers';
import { ComponentTemplateDeserialized } from '../../shared_imports';
const { setup } = pageHelpers.componentTemplateDetails;
jest.mock('ui/i18n', () => {
const I18nContext = ({ children }: any) => children;
return { I18nContext };
});
const COMPONENT_TEMPLATE: ComponentTemplateDeserialized = {
name: 'comp-1',
template: {
mappings: { properties: { ip_address: { type: 'ip' } } },
aliases: { mydata: {} },
settings: { number_of_shards: 1 },
},
version: 1,
_meta: { description: 'component template test' },
_kbnMeta: { usedBy: ['template_1'] },
};
const COMPONENT_TEMPLATE_ONLY_REQUIRED_FIELDS: ComponentTemplateDeserialized = {
name: 'comp-base',
template: {},
_kbnMeta: { usedBy: [] },
};
describe('<ComponentTemplateDetails />', () => {
const { server, httpRequestsMockHelpers } = setupEnvironment();
let testBed: ComponentTemplateDetailsTestBed;
afterAll(() => {
server.restore();
});
describe('With component template details', () => {
beforeEach(async () => {
httpRequestsMockHelpers.setLoadComponentTemplateResponse(COMPONENT_TEMPLATE);
await act(async () => {
testBed = setup({
componentTemplateName: COMPONENT_TEMPLATE.name,
onClose: () => {},
});
});
testBed.component.update();
});
test('renders the details flyout', () => {
const { exists, find, actions, component } = testBed;
// Verify flyout exists with correct title
expect(exists('componentTemplateDetails')).toBe(true);
expect(find('componentTemplateDetails.title').text()).toBe(COMPONENT_TEMPLATE.name);
// Verify footer does not display since "actions" prop was not provided
expect(exists('componentTemplateDetails.footer')).toBe(false);
// Verify tabs exist
expect(exists('settingsTab')).toBe(true);
expect(exists('mappingsTab')).toBe(true);
expect(exists('aliasesTab')).toBe(true);
// Summary tab should be active by default
expect(find('summaryTab').props()['aria-selected']).toBe(true);
// [Summary tab] Verify description list items
expect(exists('summaryTabContent.usedByTitle')).toBe(true);
expect(exists('summaryTabContent.versionTitle')).toBe(true);
expect(exists('summaryTabContent.metaTitle')).toBe(true);
// [Settings tab] Navigate to tab and verify content
act(() => {
actions.clickSettingsTab();
});
component.update();
expect(exists('settingsTabContent')).toBe(true);
// [Mappings tab] Navigate to tab and verify content
act(() => {
actions.clickMappingsTab();
});
component.update();
expect(exists('mappingsTabContent')).toBe(true);
// [Aliases tab] Navigate to tab and verify content
act(() => {
actions.clickAliasesTab();
});
component.update();
expect(exists('aliasesTabContent')).toBe(true);
});
});
describe('With only required component template fields', () => {
beforeEach(async () => {
httpRequestsMockHelpers.setLoadComponentTemplateResponse(
COMPONENT_TEMPLATE_ONLY_REQUIRED_FIELDS
);
await act(async () => {
testBed = setup({
componentTemplateName: COMPONENT_TEMPLATE_ONLY_REQUIRED_FIELDS.name,
onClose: () => {},
});
});
testBed.component.update();
});
test('renders the details flyout', () => {
const { exists, actions, component } = testBed;
// [Summary tab] Verify optional description list items do not display
expect(exists('summaryTabContent.usedByTitle')).toBe(false);
expect(exists('summaryTabContent.versionTitle')).toBe(false);
expect(exists('summaryTabContent.metaTitle')).toBe(false);
// Verify callout renders indicating the component template is not in use
expect(exists('notInUseCallout')).toBe(true);
// [Settings tab] Navigate to tab and verify info callout
act(() => {
actions.clickSettingsTab();
});
component.update();
expect(exists('noSettingsCallout')).toBe(true);
// [Mappings tab] Navigate to tab and verify info callout
act(() => {
actions.clickMappingsTab();
});
component.update();
expect(exists('noMappingsCallout')).toBe(true);
// [Aliases tab] Navigate to tab and verify info callout
act(() => {
actions.clickAliasesTab();
});
component.update();
expect(exists('noAliasesCallout')).toBe(true);
});
});
describe('With actions', () => {
beforeEach(async () => {
httpRequestsMockHelpers.setLoadComponentTemplateResponse(COMPONENT_TEMPLATE);
await act(async () => {
testBed = setup({
componentTemplateName: COMPONENT_TEMPLATE.name,
onClose: () => {},
actions: [
{
name: 'Test',
icon: 'info',
closePopoverOnClick: true,
handleActionClick: () => {},
},
],
});
});
testBed.component.update();
});
test('should render a footer with context menu', () => {
const { exists, actions, component, find } = testBed;
// Verify footer exists
expect(exists('componentTemplateDetails.footer')).toBe(true);
expect(exists('manageComponentTemplateButton')).toBe(true);
// Click manage button and verify actions
act(() => {
actions.clickManageButton();
});
component.update();
expect(exists('manageComponentTemplateContextMenu')).toBe(true);
expect(find('manageComponentTemplateContextMenu.action').length).toEqual(1);
});
});
describe('Error handling', () => {
const error = {
status: 500,
error: 'Internal server error',
message: 'Internal server error',
};
beforeEach(async () => {
httpRequestsMockHelpers.setLoadComponentTemplateResponse(undefined, { body: error });
await act(async () => {
testBed = setup({
componentTemplateName: COMPONENT_TEMPLATE.name,
onClose: () => {},
});
});
testBed.component.update();
});
test('should render an error message if error fetching pipelines', async () => {
const { exists, find } = testBed;
expect(exists('sectionError')).toBe(true);
expect(find('sectionError').text()).toContain('Error loading component template');
});
});
});

View file

@ -6,10 +6,11 @@
import { act } from 'react-dom/test-utils';
import { ComponentTemplateListItem } from '../../shared_imports';
import { setupEnvironment, pageHelpers } from './helpers';
import { ComponentTemplateListTestBed } from './helpers/component_template_list.helpers';
import { API_BASE_PATH } from '../../../../../../common/constants';
import { ComponentTemplateListItem } from '../../types';
import { API_BASE_PATH } from './helpers/constants';
const { setup } = pageHelpers.componentTemplateList;

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 { registerTestBed, TestBed } from '../../../../../../../../../test_utils';
import { WithAppDependencies } from './setup_environment';
import { ComponentTemplateDetailsFlyout } from '../../../component_template_details';
export type ComponentTemplateDetailsTestBed = TestBed<ComponentTemplateDetailsTestSubjects> & {
actions: ReturnType<typeof createActions>;
};
const createActions = (testBed: TestBed<ComponentTemplateDetailsTestSubjects>) => {
const { find } = testBed;
/**
* User Actions
*/
const clickSettingsTab = () => {
find('settingsTab').simulate('click');
};
const clickMappingsTab = () => {
find('mappingsTab').simulate('click');
};
const clickAliasesTab = () => {
find('aliasesTab').simulate('click');
};
const clickManageButton = () => {
find('manageComponentTemplateButton').simulate('click');
};
return {
clickSettingsTab,
clickAliasesTab,
clickMappingsTab,
clickManageButton,
};
};
export const setup = (props: any): ComponentTemplateDetailsTestBed => {
const setupTestBed = registerTestBed<ComponentTemplateDetailsTestSubjects>(
WithAppDependencies(ComponentTemplateDetailsFlyout),
{
memoryRouter: {
wrapComponent: false,
},
defaultProps: props,
}
);
const testBed = setupTestBed() as ComponentTemplateDetailsTestBed;
return {
...testBed,
actions: createActions(testBed),
};
};
export type ComponentTemplateDetailsTestSubjects =
| 'componentTemplateDetails'
| 'componentTemplateDetails.title'
| 'componentTemplateDetails.footer'
| 'summaryTab'
| 'mappingsTab'
| 'settingsTab'
| 'aliasesTab'
| 'sectionError'
| 'summaryTabContent'
| 'summaryTabContent.usedByTitle'
| 'summaryTabContent.versionTitle'
| 'summaryTabContent.metaTitle'
| 'notInUseCallout'
| 'aliasesTabContent'
| 'noAliasesCallout'
| 'mappingsTabContent'
| 'noMappingsCallout'
| 'settingsTabContent'
| 'noSettingsCallout'
| 'manageComponentTemplateButton'
| 'manageComponentTemplateContextMenu'
| 'manageComponentTemplateContextMenu.action';

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 const API_BASE_PATH = '/api/index_management';

View file

@ -5,11 +5,15 @@
*/
import sinon, { SinonFakeServer } from 'sinon';
import { API_BASE_PATH } from '../../../../../../../common';
import { ComponentTemplateListItem, ComponentTemplateDeserialized } from '../../../shared_imports';
import { API_BASE_PATH } from './constants';
// Register helpers to mock HTTP Requests
const registerHttpRequestMockHelpers = (server: SinonFakeServer) => {
const setLoadComponentTemplatesResponse = (response?: any[], error?: any) => {
const setLoadComponentTemplatesResponse = (
response?: ComponentTemplateListItem[],
error?: any
) => {
const status = error ? error.status || 400 : 200;
const body = error ? error.body : response;
@ -20,6 +24,20 @@ const registerHttpRequestMockHelpers = (server: SinonFakeServer) => {
]);
};
const setLoadComponentTemplateResponse = (
response?: ComponentTemplateDeserialized,
error?: any
) => {
const status = error ? error.status || 400 : 200;
const body = error ? error.body : response;
server.respondWith('GET', `${API_BASE_PATH}/component_templates/:name`, [
status,
{ 'Content-Type': 'application/json' },
JSON.stringify(body),
]);
};
const setDeleteComponentTemplateResponse = (response?: object) => {
server.respondWith('DELETE', `${API_BASE_PATH}/component_templates/:name`, [
200,
@ -31,6 +49,7 @@ const registerHttpRequestMockHelpers = (server: SinonFakeServer) => {
return {
setLoadComponentTemplatesResponse,
setDeleteComponentTemplateResponse,
setLoadComponentTemplateResponse,
};
};

View file

@ -5,6 +5,7 @@
*/
import { setup as componentTemplatesListSetup } from './component_template_list.helpers';
import { setup as componentTemplateDetailsSetup } from './component_template_details.helpers';
export { nextTick, getRandomString, findTestSubject } from '../../../../../../../../../test_utils';
@ -12,4 +13,5 @@ export { setupEnvironment } from './setup_environment';
export const pageHelpers = {
componentTemplateList: { setup: componentTemplatesListSetup },
componentTemplateDetails: { setup: componentTemplateDetailsSetup },
};

View file

@ -9,21 +9,21 @@ 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';
import { init as initHttpRequests } from './http_requests';
import { API_BASE_PATH } from './constants';
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,

View file

@ -0,0 +1,150 @@
/*
* 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 } from 'react';
import { FormattedMessage } from '@kbn/i18n/react';
import {
EuiFlyout,
EuiFlyoutHeader,
EuiTitle,
EuiFlyoutBody,
EuiFlyoutFooter,
EuiFlexGroup,
EuiFlexItem,
EuiButtonEmpty,
EuiSpacer,
EuiCallOut,
} from '@elastic/eui';
import { SectionLoading, TabSettings, TabAliases, TabMappings } from '../shared_imports';
import { useComponentTemplatesContext } from '../component_templates_context';
import { TabSummary } from './tab_summary';
import { ComponentTemplateTabs, TabType } from './tabs';
import { ManageButton, ManageAction } from './manage_button';
interface Props {
componentTemplateName: string;
onClose: () => void;
showFooter?: boolean;
actions?: ManageAction[];
}
export const ComponentTemplateDetailsFlyout: React.FunctionComponent<Props> = ({
componentTemplateName,
onClose,
actions,
}) => {
const { api } = useComponentTemplatesContext();
const { data: componentTemplateDetails, isLoading, error } = api.useLoadComponentTemplate(
componentTemplateName
);
const [activeTab, setActiveTab] = useState<TabType>('summary');
let content: React.ReactNode | undefined;
if (isLoading) {
content = (
<SectionLoading>
<FormattedMessage
id="xpack.idxMgmt.componentTemplateDetails.loadingIndexTemplateDescription"
defaultMessage="Loading component template…"
/>
</SectionLoading>
);
} else if (error) {
content = (
<EuiCallOut
title={
<FormattedMessage
id="xpack.idxMgmt.componentTemplateDetails.loadingErrorMessage"
defaultMessage="Error loading component template"
/>
}
color="danger"
iconType="alert"
data-test-subj="sectionError"
>
<p>{error.message}</p>
</EuiCallOut>
);
} else if (componentTemplateDetails) {
const {
template: { settings, mappings, aliases },
} = componentTemplateDetails;
const tabToComponentMap: Record<TabType, React.ReactNode> = {
summary: <TabSummary componentTemplateDetails={componentTemplateDetails} />,
settings: <TabSettings settings={settings} />,
mappings: <TabMappings mappings={mappings} />,
aliases: <TabAliases aliases={aliases} />,
};
const tabContent = tabToComponentMap[activeTab];
content = (
<>
<ComponentTemplateTabs activeTab={activeTab} setActiveTab={setActiveTab} />
<EuiSpacer size="l" />
{tabContent}
</>
);
}
return (
<EuiFlyout
onClose={onClose}
data-test-subj="componentTemplateDetails"
aria-labelledby="componentTemplateDetailsFlyoutTitle"
size="m"
maxWidth={500}
>
<EuiFlyoutHeader>
<EuiTitle size="m">
<h2 id="componentTemplateDetailsFlyoutTitle" data-test-subj="title">
{componentTemplateName}
</h2>
</EuiTitle>
</EuiFlyoutHeader>
<EuiFlyoutBody data-test-subj="content">{content}</EuiFlyoutBody>
{actions && (
<EuiFlyoutFooter data-test-subj="footer">
<EuiFlexGroup justifyContent="spaceBetween" alignItems="center">
{/* "Close" link */}
<EuiFlexItem grow={false}>
<EuiButtonEmpty
iconType="cross"
flush="left"
onClick={onClose}
data-test-subj="closeDetailsButton"
>
<FormattedMessage
id="xpack.idxMgmt.componentTemplateDetails.closeButtonLabel"
defaultMessage="Close"
/>
</EuiButtonEmpty>
</EuiFlexItem>
{/* "Manage" context menu */}
{componentTemplateDetails && (
<EuiFlexItem grow={false}>
<ManageButton
actions={actions}
componentTemplateDetails={componentTemplateDetails}
/>
</EuiFlexItem>
)}
</EuiFlexGroup>
</EuiFlyoutFooter>
)}
</EuiFlyout>
);
};

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 { ComponentTemplateDetailsFlyout } from './component_template_details';

View file

@ -0,0 +1,105 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React, { useState } from 'react';
import { FormattedMessage } from '@kbn/i18n/react';
import { i18n } from '@kbn/i18n';
import {
EuiPopover,
EuiButton,
EuiContextMenu,
EuiContextMenuPanelItemDescriptor,
} from '@elastic/eui';
import { ComponentTemplateDeserialized } from '../shared_imports';
export interface ManageAction {
name: string;
icon: string;
handleActionClick: () => void;
getIsDisabled?: (data: ComponentTemplateDeserialized) => boolean;
closePopoverOnClick?: boolean;
}
interface Props {
actions: ManageAction[];
componentTemplateDetails: ComponentTemplateDeserialized;
}
export const ManageButton: React.FunctionComponent<Props> = ({
actions,
componentTemplateDetails,
}) => {
const [isPopoverOpen, setIsPopOverOpen] = useState<boolean>(false);
const items: EuiContextMenuPanelItemDescriptor[] = actions.map(
({ name, icon, getIsDisabled, closePopoverOnClick, handleActionClick }) => {
const isDisabled = getIsDisabled ? getIsDisabled(componentTemplateDetails) : false;
return {
name,
icon,
disabled: isDisabled,
toolTipContent: isDisabled ? (
<FormattedMessage
id="xpack.idxMgmt.componentTemplateDetails.manageButtonDisabledTooltipLabel"
defaultMessage="Template is in use and cannot be deleted"
/>
) : null,
onClick: () => {
handleActionClick();
if (closePopoverOnClick) {
setIsPopOverOpen(false);
}
},
'data-test-subj': 'action',
};
}
);
return (
<EuiPopover
id="manageComponentTemplatePanel"
button={
<EuiButton
fill
data-test-subj="manageComponentTemplateButton"
iconType="arrowDown"
iconSide="right"
onClick={() => setIsPopOverOpen((prevBoolean) => !prevBoolean)}
>
<FormattedMessage
id="xpack.idxMgmt.componentTemplateDetails.manageButtonLabel"
defaultMessage="Manage"
/>
</EuiButton>
}
isOpen={isPopoverOpen}
closePopover={() => setIsPopOverOpen(false)}
panelPaddingSize="none"
withTitle
anchorPosition="rightUp"
repositionOnScroll
>
<EuiContextMenu
initialPanelId={0}
data-test-subj="manageComponentTemplateContextMenu"
panels={[
{
id: 0,
title: i18n.translate(
'xpack.idxMgmt.componentTemplateDetails.manageContextMenuPanelTitle',
{
defaultMessage: 'Options',
}
),
items,
},
]}
/>
</EuiPopover>
);
};

View file

@ -0,0 +1,106 @@
/*
* 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 { FormattedMessage } from '@kbn/i18n/react';
import {
EuiDescriptionList,
EuiDescriptionListTitle,
EuiDescriptionListDescription,
EuiCodeBlock,
EuiTitle,
EuiCallOut,
EuiSpacer,
} from '@elastic/eui';
import { ComponentTemplateDeserialized } from '../shared_imports';
interface Props {
componentTemplateDetails: ComponentTemplateDeserialized;
}
export const TabSummary: React.FunctionComponent<Props> = ({ componentTemplateDetails }) => {
const { version, _meta, _kbnMeta } = componentTemplateDetails;
const { usedBy } = _kbnMeta;
const templateIsInUse = usedBy.length > 0;
return (
<>
{/* Callout when component template is not in use */}
{!templateIsInUse && (
<>
<EuiCallOut
title={
<FormattedMessage
id="xpack.idxMgmt.componentTemplateDetails.summaryTab.notInUseTitle"
defaultMessage="This component template is not in use by any index templates."
/>
}
iconType="pin"
data-test-subj="notInUseCallout"
size="s"
/>
<EuiSpacer />
</>
)}
{/* Summary description list */}
<EuiDescriptionList textStyle="reverse" data-test-subj="summaryTabContent">
{/* Used by templates */}
{templateIsInUse && (
<>
<EuiDescriptionListTitle data-test-subj="usedByTitle">
<FormattedMessage
id="xpack.idxMgmt.componentTemplateDetails.summaryTab.usedByDescriptionListTitle"
defaultMessage="Used by"
/>
</EuiDescriptionListTitle>
<EuiDescriptionListDescription>
<ul>
{usedBy.map((templateName: string) => (
<li key={templateName}>
<EuiTitle size="xs">
<span>{templateName}</span>
</EuiTitle>
</li>
))}
</ul>
</EuiDescriptionListDescription>
</>
)}
{/* Version (optional) */}
{version && (
<>
<EuiDescriptionListTitle data-test-subj="versionTitle">
<FormattedMessage
id="xpack.idxMgmt.componentTemplateDetails.summaryTab.versionDescriptionListTitle"
defaultMessage="Version"
/>
</EuiDescriptionListTitle>
<EuiDescriptionListDescription>{version}</EuiDescriptionListDescription>
</>
)}
{/* Metadata (optional) */}
{_meta && (
<>
<EuiDescriptionListTitle data-test-subj="metaTitle">
<FormattedMessage
id="xpack.idxMgmt.componentTemplateDetails.summaryTab.metaDescriptionListTitle"
defaultMessage="Metadata"
/>
</EuiDescriptionListTitle>
<EuiDescriptionListDescription>
<EuiCodeBlock lang="json">{JSON.stringify(_meta, null, 2)}</EuiCodeBlock>
</EuiDescriptionListDescription>
</>
)}
</EuiDescriptionList>
</>
);
};

View file

@ -0,0 +1,70 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React from 'react';
import { i18n } from '@kbn/i18n';
import { EuiTab, EuiTabs } from '@elastic/eui';
export type TabType = 'summary' | 'mappings' | 'aliases' | 'settings';
interface Props {
setActiveTab: (id: TabType) => void;
activeTab: TabType;
}
interface Tab {
id: TabType;
name: string;
}
const TABS: Tab[] = [
{
id: 'summary',
name: i18n.translate('xpack.idxMgmt.componentTemplateDetails.summaryTabTitle', {
defaultMessage: 'Summary',
}),
},
{
id: 'settings',
name: i18n.translate('xpack.idxMgmt.componentTemplateDetails.settingsTabTitle', {
defaultMessage: 'Settings',
}),
},
{
id: 'mappings',
name: i18n.translate('xpack.idxMgmt.componentTemplateDetails.mappingsTabTitle', {
defaultMessage: 'Mappings',
}),
},
{
id: 'aliases',
name: i18n.translate('xpack.idxMgmt.componentTemplateDetails.aliasesTabTitle', {
defaultMessage: 'Aliases',
}),
},
];
export const ComponentTemplateTabs: React.FunctionComponent<Props> = ({
setActiveTab,
activeTab,
}) => {
return (
<EuiTabs>
{TABS.map((tab) => (
<EuiTab
onClick={() => {
setActiveTab(tab.id);
}}
isSelected={tab.id === activeTab}
key={tab.id}
data-test-subj={`${tab.id}Tab`}
>
{tab.name}
</EuiTab>
))}
</EuiTabs>
);
};

View file

@ -5,24 +5,39 @@
*/
import React, { useState, useEffect } from 'react';
import { RouteComponentProps } from 'react-router-dom';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n/react';
import { ScopedHistory } from 'kibana/public';
import { SectionLoading } from '../shared_imports';
import { useComponentTemplatesContext } from '../component_templates_context';
import { SectionLoading, ComponentTemplateDeserialized } from '../shared_imports';
import { UIM_COMPONENT_TEMPLATE_LIST_LOAD } from '../constants';
import { useComponentTemplatesContext } from '../component_templates_context';
import { ComponentTemplateDetailsFlyout } from '../component_template_details';
import { EmptyPrompt } from './empty_prompt';
import { ComponentTable } from './table';
import { LoadError } from './error';
import { ComponentTemplatesDeleteModal } from './delete_modal';
export const ComponentTemplateList: React.FunctionComponent = () => {
interface Props {
componentTemplateName?: string;
history: RouteComponentProps['history'];
}
export const ComponentTemplateList: React.FunctionComponent<Props> = ({
componentTemplateName,
history,
}) => {
const { api, trackMetric } = useComponentTemplatesContext();
const { data, isLoading, error, sendRequest } = api.useLoadComponentTemplates();
const [componentTemplatesToDelete, setComponentTemplatesToDelete] = useState<string[]>([]);
const goToList = () => {
return history.push('component_templates');
};
// Track component loaded
useEffect(() => {
trackMetric('loaded', UIM_COMPONENT_TEMPLATE_LIST_LOAD);
@ -49,6 +64,7 @@ export const ComponentTemplateList: React.FunctionComponent = () => {
componentTemplates={data}
onReloadClick={sendRequest}
onDeleteClick={setComponentTemplatesToDelete}
history={history as ScopedHistory}
/>
);
} else if (error) {
@ -58,18 +74,44 @@ export const ComponentTemplateList: React.FunctionComponent = () => {
return (
<div data-test-subj="componentTemplateList">
{content}
{/* delete modal */}
{componentTemplatesToDelete?.length > 0 ? (
<ComponentTemplatesDeleteModal
callback={(deleteResponse) => {
if (deleteResponse?.hasDeletedComponentTemplates) {
// refetch the component templates
sendRequest();
// go back to list view (if deleted from details flyout)
goToList();
}
setComponentTemplatesToDelete([]);
}}
componentTemplatesToDelete={componentTemplatesToDelete}
/>
) : null}
{/* details flyout */}
{componentTemplateName && (
<ComponentTemplateDetailsFlyout
onClose={goToList}
componentTemplateName={componentTemplateName}
actions={[
{
name: i18n.translate('xpack.idxMgmt.componentTemplateDetails.deleteButtonLabel', {
defaultMessage: 'Delete',
}),
icon: 'trash',
getIsDisabled: (details: ComponentTemplateDeserialized) =>
details._kbnMeta.usedBy.length > 0,
closePopoverOnClick: true,
handleActionClick: () => {
setComponentTemplatesToDelete([componentTemplateName]);
},
},
]}
/>
)}
</div>
);
};

View file

@ -5,16 +5,28 @@
*/
import React from 'react';
import { RouteComponentProps } from 'react-router-dom';
import { ComponentTemplatesAuthProvider } from './auth_provider';
import { ComponentTemplatesWithPrivileges } from './with_privileges';
import { ComponentTemplateList } from './component_template_list';
export const ComponentTemplateListContainer: React.FunctionComponent = () => {
interface MatchParams {
componentTemplateName?: string;
}
export const ComponentTemplateListContainer: React.FunctionComponent<RouteComponentProps<
MatchParams
>> = ({
match: {
params: { componentTemplateName },
},
history,
}) => {
return (
<ComponentTemplatesAuthProvider>
<ComponentTemplatesWithPrivileges>
<ComponentTemplateList />
<ComponentTemplateList componentTemplateName={componentTemplateName} history={history} />
</ComponentTemplatesWithPrivileges>
</ComponentTemplatesAuthProvider>
);

View file

@ -12,21 +12,30 @@ import {
EuiInMemoryTableProps,
EuiTextColor,
EuiIcon,
EuiLink,
} from '@elastic/eui';
import { ScopedHistory } from 'kibana/public';
import { ComponentTemplateListItem } from '../types';
import { reactRouterNavigate } from '../../../../../../../../src/plugins/kibana_react/public';
import { ComponentTemplateListItem } from '../shared_imports';
import { UIM_COMPONENT_TEMPLATE_DETAILS } from '../constants';
import { useComponentTemplatesContext } from '../component_templates_context';
export interface Props {
componentTemplates: ComponentTemplateListItem[];
onReloadClick: () => void;
onDeleteClick: (componentTemplateName: string[]) => void;
history: ScopedHistory;
}
export const ComponentTable: FunctionComponent<Props> = ({
componentTemplates,
onReloadClick,
onDeleteClick,
history,
}) => {
const { trackMetric } = useComponentTemplatesContext();
const [selection, setSelection] = useState<ComponentTemplateListItem[]>([]);
const tableProps: EuiInMemoryTableProps<ComponentTemplateListItem> = {
@ -120,6 +129,21 @@ export const ComponentTable: FunctionComponent<Props> = ({
defaultMessage: 'Name',
}),
sortable: true,
render: (name: string) => (
/* eslint-disable-next-line @elastic/eui/href-or-on-click */
<EuiLink
{...reactRouterNavigate(
history,
{
pathname: `/component_templates/${name}`,
},
() => trackMetric('click', UIM_COMPONENT_TEMPLATE_DETAILS)
)}
data-test-subj="templateDetailsLink"
>
{name}
</EuiLink>
),
},
{
field: 'usedBy',

View file

@ -14,7 +14,6 @@ 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'];
@ -27,7 +26,6 @@ interface Context {
documentation: ReturnType<typeof getDocumentation>;
trackMetric: (type: 'loaded' | 'click' | 'count', eventName: string) => void;
toasts: NotificationsSetup['toasts'];
appBasePath: string;
}
export const ComponentTemplatesProvider = ({
@ -37,7 +35,7 @@ export const ComponentTemplatesProvider = ({
value: Props;
children: React.ReactNode;
}) => {
const { httpClient, apiBasePath, trackMetric, docLinks, toasts, appBasePath } = value;
const { httpClient, apiBasePath, trackMetric, docLinks, toasts } = value;
const useRequest = getUseRequest(httpClient);
const sendRequest = getSendRequest(httpClient);
@ -47,7 +45,7 @@ export const ComponentTemplatesProvider = ({
return (
<ComponentTemplatesContext.Provider
value={{ api, documentation, trackMetric, toasts, appBasePath, httpClient, apiBasePath }}
value={{ api, documentation, trackMetric, toasts, httpClient, apiBasePath }}
>
{children}
</ComponentTemplatesContext.Provider>

View file

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

View file

@ -8,4 +8,4 @@ export { ComponentTemplatesProvider } from './component_templates_context';
export { ComponentTemplateList } from './component_template_list';
export * from './types';
export { ComponentTemplateDetailsFlyout } from './component_template_details';

View file

@ -4,9 +4,9 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { ComponentTemplateListItem } from '../types';
import { UseRequestHook, SendRequestHook } from './request';
import { ComponentTemplateListItem, ComponentTemplateDeserialized } from '../shared_imports';
import { UIM_COMPONENT_TEMPLATE_DELETE_MANY, UIM_COMPONENT_TEMPLATE_DELETE } from '../constants';
import { UseRequestHook, SendRequestHook } from './request';
export const getApi = (
useRequest: UseRequestHook,
@ -37,8 +37,16 @@ export const getApi = (
return result;
}
function useLoadComponentTemplate(name: string) {
return useRequest<ComponentTemplateDeserialized>({
path: `${apiBasePath}/component_templates/${encodeURIComponent(name)}`,
method: 'get',
});
}
return {
useLoadComponentTemplates,
deleteComponentTemplates,
useLoadComponentTemplate,
};
};

View file

@ -19,3 +19,11 @@ export {
useAuthorizationContext,
NotAuthorizedSection,
} from '../../../../../../../src/plugins/es_ui_shared/public';
export { TabMappings, TabSettings, TabAliases } from '../shared';
export {
ComponentTemplateSerialized,
ComponentTemplateDeserialized,
ComponentTemplateListItem,
} from '../../../../common';

View file

@ -1,17 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* 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

@ -0,0 +1,9 @@
/*
* 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 { TabAliases } from './tab_aliases';
export { TabMappings } from './tab_mappings';
export { TabSettings } from './tab_settings';

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 React from 'react';
import { FormattedMessage } from '@kbn/i18n/react';
import { EuiCodeBlock, EuiCallOut } from '@elastic/eui';
import { Aliases } from '../../../../../../common';
interface Props {
aliases: Aliases | undefined;
}
export const TabAliases: React.FunctionComponent<Props> = ({ aliases }) => {
if (aliases && Object.keys(aliases).length) {
return (
<div data-test-subj="aliasesTabContent">
<EuiCodeBlock lang="json">{JSON.stringify(aliases, null, 2)}</EuiCodeBlock>
</div>
);
}
return (
<EuiCallOut
title={
<FormattedMessage
id="xpack.idxMgmt.aliasesTab.noAliasesTitle"
defaultMessage="No aliases defined."
/>
}
iconType="pin"
data-test-subj="noAliasesCallout"
size="s"
/>
);
};

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 from 'react';
import { FormattedMessage } from '@kbn/i18n/react';
import { EuiCodeBlock, EuiCallOut } from '@elastic/eui';
import { Mappings } from '../../../../../../common';
interface Props {
mappings: Mappings | undefined;
}
export const TabMappings: React.FunctionComponent<Props> = ({ mappings }) => {
if (mappings && Object.keys(mappings).length) {
return (
<div data-test-subj="mappingsTabContent">
<EuiCodeBlock lang="json">{JSON.stringify(mappings, null, 2)}</EuiCodeBlock>
</div>
);
}
return (
<EuiCallOut
title={
<FormattedMessage
id="xpack.idxMgmt.mappingsTab.noMappingsTitle"
defaultMessage="No mappings defined."
/>
}
iconType="pin"
data-test-subj="noMappingsCallout"
size="s"
/>
);
};

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 from 'react';
import { FormattedMessage } from '@kbn/i18n/react';
import { EuiCodeBlock, EuiCallOut } from '@elastic/eui';
import { IndexSettings } from '../../../../../../common';
interface Props {
settings: IndexSettings | undefined;
}
export const TabSettings: React.FunctionComponent<Props> = ({ settings }) => {
if (settings && Object.keys(settings).length) {
return (
<div data-test-subj="settingsTabContent">
<EuiCodeBlock lang="json">{JSON.stringify(settings, null, 2)}</EuiCodeBlock>
</div>
);
}
return (
<EuiCallOut
title={
<FormattedMessage
id="xpack.idxMgmt.settingsTab.noIndexSettingsTitle"
defaultMessage="No settings defined."
/>
}
iconType="pin"
data-test-subj="noSettingsCallout"
size="s"
/>
);
};

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 { TabAliases, TabMappings, TabSettings } from './details_panel';

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 { TabAliases, TabMappings, TabSettings } from './components';

View file

@ -10,7 +10,7 @@ import { render, unmountComponentAtNode } from 'react-dom';
import { CoreStart } from '../../../../../src/core/public';
import { API_BASE_PATH, BASE_PATH } from '../../common';
import { API_BASE_PATH } from '../../common';
import { AppContextProvider, AppDependencies } from './app_context';
import { App } from './app';
@ -32,7 +32,6 @@ export const renderApp = (
const componentTemplateProviderValues = {
httpClient: services.httpService.httpClient,
apiBasePath: API_BASE_PATH,
appBasePath: BASE_PATH,
trackMetric: services.uiMetricService.trackMetric.bind(services.uiMetricService),
docLinks,
toasts: notifications.toasts,

View file

@ -150,7 +150,14 @@ export const IndexManagementHome: React.FunctionComponent<RouteComponentProps<Ma
path={[`/${Section.IndexTemplates}`, `/${Section.IndexTemplates}/:templateName?`]}
component={TemplateList}
/>
<Route exact path={`/${Section.ComponentTemplates}`} component={ComponentTemplateList} />
<Route
exact
path={[
`/${Section.ComponentTemplates}`,
`/${Section.ComponentTemplates}/:componentTemplateName?`,
]}
component={ComponentTemplateList}
/>
</Switch>
</EuiPageContent>
</EuiPageBody>