[Composable template] Preview composite template (#72598)

Co-authored-by: Jean-Louis Leysens <jloleysens@gmail.com>
Co-authored-by: Elastic Machine <elasticmachine@users.noreply.github.com>
This commit is contained in:
Sébastien Loix 2020-07-24 07:50:37 +02:00 committed by GitHub
parent c2ad4bf048
commit 1329b683de
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
85 changed files with 1870 additions and 975 deletions

View file

@ -0,0 +1,166 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import React, {
createContext,
useContext,
useState,
useCallback,
useMemo,
useEffect,
useRef,
} from 'react';
import { EuiFlyout } from '@elastic/eui';
interface Context {
addContent: <P extends object = { [key: string]: any }>(content: Content<P>) => void;
removeContent: (contentId: string) => void;
closeFlyout: () => void;
}
interface Content<P extends object = { [key: string]: any }> {
id: string;
Component: React.FunctionComponent<P>;
props?: P;
flyoutProps?: { [key: string]: any };
cleanUpFunc?: () => void;
}
const FlyoutMultiContentContext = createContext<Context | undefined>(undefined);
const DEFAULT_FLYOUT_PROPS = {
'data-test-subj': 'flyout',
size: 'm' as 'm',
maxWidth: 500,
};
export const GlobalFlyoutProvider: React.FC = ({ children }) => {
const [showFlyout, setShowFlyout] = useState(false);
const [activeContent, setActiveContent] = useState<Content<any> | undefined>(undefined);
const { id, Component, props, flyoutProps } = activeContent ?? {};
const addContent: Context['addContent'] = useCallback((content) => {
setActiveContent((prev) => {
if (prev !== undefined) {
if (prev.id !== content.id && prev.cleanUpFunc) {
// Clean up anything from the content about to be removed
prev.cleanUpFunc();
}
}
return content;
});
setShowFlyout(true);
}, []);
const closeFlyout: Context['closeFlyout'] = useCallback(() => {
setActiveContent(undefined);
setShowFlyout(false);
}, []);
const removeContent: Context['removeContent'] = useCallback(
(contentId: string) => {
if (contentId === id) {
closeFlyout();
}
},
[id, closeFlyout]
);
const mergedFlyoutProps = useMemo(() => {
return {
...DEFAULT_FLYOUT_PROPS,
onClose: closeFlyout,
...flyoutProps,
};
}, [flyoutProps, closeFlyout]);
const context: Context = {
addContent,
removeContent,
closeFlyout,
};
const ContentFlyout = showFlyout && Component !== undefined ? Component : null;
return (
<FlyoutMultiContentContext.Provider value={context}>
<>
{children}
{ContentFlyout && (
<EuiFlyout {...mergedFlyoutProps}>
<ContentFlyout {...props} />
</EuiFlyout>
)}
</>
</FlyoutMultiContentContext.Provider>
);
};
export const useGlobalFlyout = () => {
const ctx = useContext(FlyoutMultiContentContext);
if (ctx === undefined) {
throw new Error('useGlobalFlyout must be used within a <GlobalFlyoutProvider />');
}
const isMounted = useRef(false);
/**
* A component can add one or multiple content to the flyout
* during its lifecycle. When it unmounts, we will remove
* all those content added to the flyout.
*/
const contents = useRef<Set<string> | undefined>(undefined);
const { removeContent, addContent: addContentToContext } = ctx;
useEffect(() => {
isMounted.current = true;
return () => {
isMounted.current = false;
};
}, []);
const getContents = useCallback(() => {
if (contents.current === undefined) {
contents.current = new Set();
}
return contents.current;
}, []);
const addContent: Context['addContent'] = useCallback(
(content) => {
getContents().add(content.id);
return addContentToContext(content);
},
[getContents, addContentToContext]
);
useEffect(() => {
return () => {
if (!isMounted.current) {
// When the component unmounts, remove all the content it has added to the flyout
Array.from(getContents()).forEach(removeContent);
}
};
}, [removeContent]);
return { ...ctx, addContent };
};

View file

@ -0,0 +1,20 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
export { GlobalFlyoutProvider, useGlobalFlyout } from './global_flyout';

View file

@ -27,13 +27,14 @@ import {
} from './form_wizard_context';
import { FormWizardNav, NavTexts } from './form_wizard_nav';
interface Props<T extends object> extends ProviderProps<T> {
interface Props<T extends object, S extends string> extends ProviderProps<T> {
isSaving?: boolean;
apiError: JSX.Element | null;
texts?: Partial<NavTexts>;
rightContentNav?: JSX.Element | null | ((stepId: S) => JSX.Element | null);
}
export function FormWizard<T extends object = { [key: string]: any }>({
export function FormWizard<T extends object = { [key: string]: any }, S extends string = any>({
texts,
defaultActiveStep,
defaultValue,
@ -43,7 +44,8 @@ export function FormWizard<T extends object = { [key: string]: any }>({
onSave,
onChange,
children,
}: Props<T>) {
rightContentNav,
}: Props<T, S>) {
return (
<FormWizardProvider<T>
defaultValue={defaultValue}
@ -53,7 +55,14 @@ export function FormWizard<T extends object = { [key: string]: any }>({
defaultActiveStep={defaultActiveStep}
>
<FormWizardConsumer>
{({ activeStepIndex, lastStep, steps, isCurrentStepValid, navigateToStep }) => {
{({
activeStepIndex,
lastStep,
steps,
isCurrentStepValid,
navigateToStep,
activeStepId,
}) => {
const stepsRequiredArray = Object.values(steps).map(
(step) => Boolean(step.isRequired) && step.isComplete === false
);
@ -95,6 +104,13 @@ export function FormWizard<T extends object = { [key: string]: any }>({
};
});
const getRightContentNav = () => {
if (typeof rightContentNav === 'function') {
return rightContentNav(activeStepId);
}
return rightContentNav;
};
const onBack = () => {
const prevStep = activeStepIndex - 1;
navigateToStep(prevStep);
@ -129,6 +145,7 @@ export function FormWizard<T extends object = { [key: string]: any }>({
onBack={onBack}
onNext={onNext}
texts={texts}
getRightContent={getRightContentNav}
/>
</>
);

View file

@ -29,6 +29,7 @@ interface Props {
isSaving?: boolean;
isStepValid?: boolean;
texts?: Partial<NavTexts>;
getRightContent?: () => JSX.Element | null | undefined;
}
export interface NavTexts {
@ -53,6 +54,7 @@ export const FormWizardNav = ({
onBack,
onNext,
texts,
getRightContent,
}: Props) => {
const isLastStep = activeStepIndex === lastStep;
const labels = {
@ -66,6 +68,8 @@ export const FormWizardNav = ({
: labels.save
: labels.next;
const rightContent = getRightContent !== undefined ? getRightContent() : undefined;
return (
<EuiFlexGroup justifyContent="spaceBetween">
<EuiFlexItem grow={false}>
@ -100,6 +104,8 @@ export const FormWizardNav = ({
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
{rightContent && <EuiFlexItem grow={false}>{rightContent}</EuiFlexItem>}
</EuiFlexGroup>
);
};

View file

@ -94,7 +94,7 @@ export function useMultiContent<T extends object>({
const activeContentData: Partial<T> = {};
for (const [id, _content] of Object.entries(contents.current)) {
if (validation.contents[id as keyof T]) {
if (validation.contents[id as keyof T] !== false) {
const contentData = (_content as Content).getData();
// Replace the getData() handler with the cached value
@ -161,7 +161,7 @@ export function useMultiContent<T extends object>({
);
/**
* Validate the multi-content active content(s) in the DOM
* Validate the content(s) currently in the DOM
*/
const validate = useCallback(async () => {
if (Object.keys(contents.current).length === 0) {

View file

@ -0,0 +1,23 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
export {
GlobalFlyoutProvider,
useGlobalFlyout,
} from '../../__packages_do_not_import__/global_flyout';

View file

@ -24,6 +24,7 @@
import * as Forms from './forms';
import * as Monaco from './monaco';
import * as ace from './ace';
import * as GlobalFlyout from './global_flyout';
export { JsonEditor, OnJsonEditorUpdateHandler } from './components/json_editor';
@ -65,7 +66,7 @@ export {
useAuthorizationContext,
} from './authorization';
export { Monaco, Forms, ace };
export { Monaco, Forms, ace, GlobalFlyout };
export { extractQueryParams } from './url';

View file

@ -64,9 +64,13 @@ interface StripEmptyFieldsOptions {
* @param options An optional configuration object. By default recursive it turned on.
*/
export const stripEmptyFields = (
object: { [key: string]: any },
object?: { [key: string]: any },
options?: StripEmptyFieldsOptions
): { [key: string]: any } => {
if (object === undefined) {
return {};
}
const { types = ['string', 'object'], recursive = false } = options || {};
return Object.entries(object).reduce((acc, [key, value]) => {

View file

@ -92,6 +92,17 @@ const registerHttpRequestMockHelpers = (server: SinonFakeServer) => {
]);
};
const setSimulateTemplateResponse = (response?: HttpResponse, error?: any) => {
const status = error ? error.status || 400 : 200;
const body = error ? JSON.stringify(error.body) : JSON.stringify(response);
server.respondWith('POST', `${API_BASE_PATH}/index_templates/simulate`, [
status,
{ 'Content-Type': 'application/json' },
body,
]);
};
return {
setLoadTemplatesResponse,
setLoadIndicesResponse,
@ -102,6 +113,7 @@ const registerHttpRequestMockHelpers = (server: SinonFakeServer) => {
setLoadTemplateResponse,
setCreateTemplateResponse,
setUpdateTemplateResponse,
setSimulateTemplateResponse,
};
};

View file

@ -14,6 +14,8 @@ import {
notificationServiceMock,
docLinksServiceMock,
} from '../../../../../../src/core/public/mocks';
import { GlobalFlyout } from '../../../../../../src/plugins/es_ui_shared/public';
import { AppContextProvider } from '../../../public/application/app_context';
import { httpService } from '../../../public/application/services/http';
import { breadcrumbService } from '../../../public/application/services/breadcrumbs';
@ -23,9 +25,11 @@ import { ExtensionsService } from '../../../public/services';
import { UiMetricService } from '../../../public/application/services/ui_metric';
import { setUiMetricService } from '../../../public/application/services/api';
import { setExtensionsService } from '../../../public/application/store/selectors';
import { MappingsEditorProvider } from '../../../public/application/components';
import { init as initHttpRequests } from './http_requests';
const mockHttpClient = axios.create({ adapter: axiosXhrAdapter });
const { GlobalFlyoutProvider } = GlobalFlyout;
export const services = {
extensionsService: new ExtensionsService(),
@ -62,7 +66,11 @@ export const WithAppDependencies = (Comp: any, overridingDependencies: any = {})
const mergedDependencies = merge({}, appDependencies, overridingDependencies);
return (
<AppContextProvider value={mergedDependencies}>
<Comp {...props} />
<MappingsEditorProvider>
<GlobalFlyoutProvider>
<Comp {...props} />
</GlobalFlyoutProvider>
</MappingsEditorProvider>
</AppContextProvider>
);
};

View file

@ -28,6 +28,7 @@ export type TestSubjects =
| 'legacyTemplateTable'
| 'manageTemplateButton'
| 'mappingsTabContent'
| 'previewTabContent'
| 'noAliasesCallout'
| 'noMappingsCallout'
| 'noSettingsCallout'
@ -48,4 +49,5 @@ export type TestSubjects =
| 'templateList'
| 'templatesTab'
| 'templateTable'
| 'viewButton';
| 'viewButton'
| 'simulateTemplatePreview';

View file

@ -40,10 +40,15 @@ const createActions = (testBed: TestBed<TestSubjects>) => {
/**
* User Actions
*/
const selectDetailsTab = (tab: 'summary' | 'settings' | 'mappings' | 'aliases') => {
const tabs = ['summary', 'settings', 'mappings', 'aliases'];
const selectDetailsTab = async (
tab: 'summary' | 'settings' | 'mappings' | 'aliases' | 'preview'
) => {
const tabs = ['summary', 'settings', 'mappings', 'aliases', 'preview'];
testBed.find('templateDetails.tab').at(tabs.indexOf(tab)).simulate('click');
await act(async () => {
testBed.find('templateDetails.tab').at(tabs.indexOf(tab)).simulate('click');
});
testBed.component.update();
};
const clickReloadButton = () => {

View file

@ -493,7 +493,7 @@ describe('Index Templates tab', () => {
});
describe('tabs', () => {
test('should have 4 tabs', async () => {
test('should have 5 tabs', async () => {
const template = fixtures.getTemplate({
name: `a${getRandomString()}`,
indexPatterns: ['template1Pattern1*', 'template1Pattern2'],
@ -524,35 +524,48 @@ describe('Index Templates tab', () => {
const { find, actions, exists } = testBed;
httpRequestsMockHelpers.setLoadTemplateResponse(template);
httpRequestsMockHelpers.setSimulateTemplateResponse({ simulateTemplate: 'response' });
await actions.clickTemplateAt(0);
expect(find('templateDetails.tab').length).toBe(4);
expect(find('templateDetails.tab').length).toBe(5);
expect(find('templateDetails.tab').map((t) => t.text())).toEqual([
'Summary',
'Settings',
'Mappings',
'Aliases',
'Preview',
]);
// Summary tab should be initial active tab
expect(exists('summaryTab')).toBe(true);
// Navigate and verify all tabs
actions.selectDetailsTab('settings');
await actions.selectDetailsTab('settings');
expect(exists('summaryTab')).toBe(false);
expect(exists('settingsTabContent')).toBe(true);
actions.selectDetailsTab('aliases');
await actions.selectDetailsTab('aliases');
expect(exists('summaryTab')).toBe(false);
expect(exists('settingsTabContent')).toBe(false);
expect(exists('aliasesTabContent')).toBe(true);
actions.selectDetailsTab('mappings');
await actions.selectDetailsTab('mappings');
expect(exists('summaryTab')).toBe(false);
expect(exists('settingsTabContent')).toBe(false);
expect(exists('aliasesTabContent')).toBe(false);
expect(exists('mappingsTabContent')).toBe(true);
await actions.selectDetailsTab('preview');
expect(exists('summaryTab')).toBe(false);
expect(exists('settingsTabContent')).toBe(false);
expect(exists('aliasesTabContent')).toBe(false);
expect(exists('mappingsTabContent')).toBe(false);
expect(exists('previewTabContent')).toBe(true);
expect(find('simulateTemplatePreview').text().replace(/\s/g, '')).toEqual(
JSON.stringify({ simulateTemplate: 'response' })
);
});
test('should show an info callout if data is not present', async () => {
@ -568,17 +581,17 @@ describe('Index Templates tab', () => {
await actions.clickTemplateAt(0);
expect(find('templateDetails.tab').length).toBe(4);
expect(find('templateDetails.tab').length).toBe(5);
expect(exists('summaryTab')).toBe(true);
// Navigate and verify callout message per tab
actions.selectDetailsTab('settings');
await actions.selectDetailsTab('settings');
expect(exists('noSettingsCallout')).toBe(true);
actions.selectDetailsTab('mappings');
await actions.selectDetailsTab('mappings');
expect(exists('noMappingsCallout')).toBe(true);
actions.selectDetailsTab('aliases');
await actions.selectDetailsTab('aliases');
expect(exists('noAliasesCallout')).toBe(true);
});
});

View file

@ -47,7 +47,9 @@ export {
UIM_TEMPLATE_DETAIL_PANEL_SETTINGS_TAB,
UIM_TEMPLATE_DETAIL_PANEL_MAPPINGS_TAB,
UIM_TEMPLATE_DETAIL_PANEL_ALIASES_TAB,
UIM_TEMPLATE_DETAIL_PANEL_PREVIEW_TAB,
UIM_TEMPLATE_CREATE,
UIM_TEMPLATE_UPDATE,
UIM_TEMPLATE_CLONE,
UIM_TEMPLATE_SIMULATE,
} from './ui_metric';

View file

@ -41,6 +41,8 @@ export const UIM_TEMPLATE_DETAIL_PANEL_SUMMARY_TAB = 'template_details_summary_t
export const UIM_TEMPLATE_DETAIL_PANEL_SETTINGS_TAB = 'template_details_settings_tab';
export const UIM_TEMPLATE_DETAIL_PANEL_MAPPINGS_TAB = 'template_details_mappings_tab';
export const UIM_TEMPLATE_DETAIL_PANEL_ALIASES_TAB = 'template_details_aliases_tab';
export const UIM_TEMPLATE_DETAIL_PANEL_PREVIEW_TAB = 'template_details_preview_tab';
export const UIM_TEMPLATE_CREATE = 'template_create';
export const UIM_TEMPLATE_UPDATE = 'template_update';
export const UIM_TEMPLATE_CLONE = 'template_clone';
export const UIM_TEMPLATE_SIMULATE = 'template_simulate';

View file

@ -109,7 +109,7 @@ export function serializeLegacyTemplate(template: TemplateDeserialized): LegacyT
version,
order,
indexPatterns,
template: { settings, aliases, mappings },
template: { settings, aliases, mappings } = {},
} = template;
return {

View file

@ -61,11 +61,10 @@ describe('<ComponentTemplateDetails />', () => {
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);
expect(find('title').text()).toBe(COMPONENT_TEMPLATE.name);
// Verify footer does not display since "actions" prop was not provided
expect(exists('componentTemplateDetails.footer')).toBe(false);
expect(exists('footer')).toBe(false);
// Verify tabs exist
expect(exists('settingsTab')).toBe(true);
@ -185,7 +184,7 @@ describe('<ComponentTemplateDetails />', () => {
const { exists, actions, component, find } = testBed;
// Verify footer exists
expect(exists('componentTemplateDetails.footer')).toBe(true);
expect(exists('footer')).toBe(true);
expect(exists('manageComponentTemplateButton')).toBe(true);
// Click manage button and verify actions

View file

@ -6,7 +6,7 @@
import { registerTestBed, TestBed } from '../../../../../../../../../test_utils';
import { WithAppDependencies } from './setup_environment';
import { ComponentTemplateDetailsFlyout } from '../../../component_template_details';
import { ComponentTemplateDetailsFlyoutContent } from '../../../component_template_details';
export type ComponentTemplateDetailsTestBed = TestBed<ComponentTemplateDetailsTestSubjects> & {
actions: ReturnType<typeof createActions>;
@ -44,7 +44,7 @@ const createActions = (testBed: TestBed<ComponentTemplateDetailsTestSubjects>) =
export const setup = (props: any): ComponentTemplateDetailsTestBed => {
const setupTestBed = registerTestBed<ComponentTemplateDetailsTestSubjects>(
WithAppDependencies(ComponentTemplateDetailsFlyout),
WithAppDependencies(ComponentTemplateDetailsFlyoutContent),
{
memoryRouter: {
wrapComponent: false,
@ -65,6 +65,8 @@ export type ComponentTemplateDetailsTestSubjects =
| 'componentTemplateDetails'
| 'componentTemplateDetails.title'
| 'componentTemplateDetails.footer'
| 'title'
| 'footer'
| 'summaryTab'
| 'mappingsTab'
| 'settingsTab'

View file

@ -15,12 +15,15 @@ import {
applicationServiceMock,
} from '../../../../../../../../../../src/core/public/mocks';
import { GlobalFlyout } from '../../../../../../../../../../src/plugins/es_ui_shared/public';
import { MappingsEditorProvider } from '../../../../mappings_editor';
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 { GlobalFlyoutProvider } = GlobalFlyout;
const appDependencies = {
httpClient: (mockHttpClient as unknown) as HttpSetup,
@ -42,7 +45,11 @@ export const setupEnvironment = () => {
};
export const WithAppDependencies = (Comp: any) => (props: any) => (
<ComponentTemplatesProvider value={appDependencies}>
<Comp {...props} />
</ComponentTemplatesProvider>
<MappingsEditorProvider>
<ComponentTemplatesProvider value={appDependencies}>
<GlobalFlyoutProvider>
<Comp {...props} />
</GlobalFlyoutProvider>
</ComponentTemplatesProvider>
</MappingsEditorProvider>
);

View file

@ -8,7 +8,6 @@ import React, { useState } from 'react';
import { FormattedMessage } from '@kbn/i18n/react';
import {
EuiFlyout,
EuiFlyoutHeader,
EuiTitle,
EuiFlyoutBody,
@ -28,14 +27,19 @@ import { ComponentTemplateTabs, TabType } from './tabs';
import { ManageButton, ManageAction } from './manage_button';
import { attemptToDecodeURI } from '../lib';
interface Props {
export interface Props {
componentTemplateName: string;
onClose: () => void;
actions?: ManageAction[];
showSummaryCallToAction?: boolean;
}
export const ComponentTemplateDetailsFlyout: React.FunctionComponent<Props> = ({
export const defaultFlyoutProps = {
'data-test-subj': 'componentTemplateDetails',
'aria-labelledby': 'componentTemplateDetailsFlyoutTitle',
};
export const ComponentTemplateDetailsFlyoutContent: React.FunctionComponent<Props> = ({
componentTemplateName,
onClose,
actions,
@ -109,13 +113,7 @@ export const ComponentTemplateDetailsFlyout: React.FunctionComponent<Props> = ({
}
return (
<EuiFlyout
onClose={onClose}
data-test-subj="componentTemplateDetails"
aria-labelledby="componentTemplateDetailsFlyoutTitle"
size="m"
maxWidth={500}
>
<>
<EuiFlyoutHeader>
<EuiFlexGroup alignItems="center" gutterSize="s">
<EuiFlexItem grow={false}>
@ -172,6 +170,6 @@ export const ComponentTemplateDetailsFlyout: React.FunctionComponent<Props> = ({
</EuiFlexGroup>
</EuiFlyoutFooter>
)}
</EuiFlyout>
</>
);
};

View file

@ -4,4 +4,8 @@
* you may not use this file except in compliance with the Elastic License.
*/
export { ComponentTemplateDetailsFlyout } from './component_template_details';
export {
ComponentTemplateDetailsFlyoutContent,
defaultFlyoutProps,
Props as ComponentTemplateDetailsProps,
} from './component_template_details';

View file

@ -4,18 +4,22 @@
* you may not use this file except in compliance with the Elastic License.
*/
import React, { useState, useEffect } from 'react';
import React, { useState, useEffect, useCallback } 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 { EuiLink, EuiText, EuiSpacer } from '@elastic/eui';
import { SectionLoading, ComponentTemplateDeserialized } from '../shared_imports';
import { SectionLoading, ComponentTemplateDeserialized, GlobalFlyout } from '../shared_imports';
import { UIM_COMPONENT_TEMPLATE_LIST_LOAD } from '../constants';
import { attemptToDecodeURI } from '../lib';
import { useComponentTemplatesContext } from '../component_templates_context';
import { ComponentTemplateDetailsFlyout } from '../component_template_details';
import {
ComponentTemplateDetailsFlyoutContent,
defaultFlyoutProps,
ComponentTemplateDetailsProps,
} from '../component_template_details';
import { EmptyPrompt } from './empty_prompt';
import { ComponentTable } from './table';
import { LoadError } from './error';
@ -26,39 +30,112 @@ interface Props {
history: RouteComponentProps['history'];
}
const { useGlobalFlyout } = GlobalFlyout;
export const ComponentTemplateList: React.FunctionComponent<Props> = ({
componentTemplateName,
history,
}) => {
const {
addContent: addContentToGlobalFlyout,
removeContent: removeContentFromGlobalFlyout,
} = useGlobalFlyout();
const { api, trackMetric, documentation } = useComponentTemplatesContext();
const { data, isLoading, error, sendRequest } = api.useLoadComponentTemplates();
const [componentTemplatesToDelete, setComponentTemplatesToDelete] = useState<string[]>([]);
const goToComponentTemplateList = () => {
const goToComponentTemplateList = useCallback(() => {
return history.push({
pathname: 'component_templates',
});
};
}, [history]);
const goToEditComponentTemplate = (name: string) => {
return history.push({
pathname: encodeURI(`edit_component_template/${encodeURIComponent(name)}`),
});
};
const goToEditComponentTemplate = useCallback(
(name: string) => {
return history.push({
pathname: encodeURI(`edit_component_template/${encodeURIComponent(name)}`),
});
},
[history]
);
const goToCloneComponentTemplate = (name: string) => {
return history.push({
pathname: encodeURI(`create_component_template/${encodeURIComponent(name)}`),
});
};
const goToCloneComponentTemplate = useCallback(
(name: string) => {
return history.push({
pathname: encodeURI(`create_component_template/${encodeURIComponent(name)}`),
});
},
[history]
);
// Track component loaded
useEffect(() => {
trackMetric('loaded', UIM_COMPONENT_TEMPLATE_LIST_LOAD);
}, [trackMetric]);
useEffect(() => {
if (componentTemplateName) {
const actions = [
{
name: i18n.translate('xpack.idxMgmt.componentTemplateDetails.editButtonLabel', {
defaultMessage: 'Edit',
}),
icon: 'pencil',
handleActionClick: () =>
goToEditComponentTemplate(attemptToDecodeURI(componentTemplateName)),
},
{
name: i18n.translate('xpack.idxMgmt.componentTemplateDetails.cloneActionLabel', {
defaultMessage: 'Clone',
}),
icon: 'copy',
handleActionClick: () =>
goToCloneComponentTemplate(attemptToDecodeURI(componentTemplateName)),
},
{
name: i18n.translate('xpack.idxMgmt.componentTemplateDetails.deleteButtonLabel', {
defaultMessage: 'Delete',
}),
icon: 'trash',
getIsDisabled: (details: ComponentTemplateDeserialized) =>
details._kbnMeta.usedBy.length > 0,
closePopoverOnClick: true,
handleActionClick: () => {
setComponentTemplatesToDelete([attemptToDecodeURI(componentTemplateName)]);
},
},
];
// Open the flyout with the Component Template Details content
addContentToGlobalFlyout<ComponentTemplateDetailsProps>({
id: 'componentTemplateDetails',
Component: ComponentTemplateDetailsFlyoutContent,
props: {
onClose: goToComponentTemplateList,
componentTemplateName,
showSummaryCallToAction: true,
actions,
},
flyoutProps: { ...defaultFlyoutProps, onClose: goToComponentTemplateList },
});
}
}, [
componentTemplateName,
goToComponentTemplateList,
goToEditComponentTemplate,
goToCloneComponentTemplate,
addContentToGlobalFlyout,
history,
]);
useEffect(() => {
if (!componentTemplateName) {
removeContentFromGlobalFlyout('componentTemplateDetails');
}
}, [componentTemplateName, removeContentFromGlobalFlyout]);
let content: React.ReactNode;
if (isLoading) {
@ -126,45 +203,6 @@ export const ComponentTemplateList: React.FunctionComponent<Props> = ({
componentTemplatesToDelete={componentTemplatesToDelete}
/>
) : null}
{/* details flyout */}
{componentTemplateName && (
<ComponentTemplateDetailsFlyout
onClose={goToComponentTemplateList}
componentTemplateName={componentTemplateName}
showSummaryCallToAction={true}
actions={[
{
name: i18n.translate('xpack.idxMgmt.componentTemplateDetails.editButtonLabel', {
defaultMessage: 'Edit',
}),
icon: 'pencil',
handleActionClick: () =>
goToEditComponentTemplate(attemptToDecodeURI(componentTemplateName)),
},
{
name: i18n.translate('xpack.idxMgmt.componentTemplateDetails.cloneActionLabel', {
defaultMessage: 'Clone',
}),
icon: 'copy',
handleActionClick: () =>
goToCloneComponentTemplate(attemptToDecodeURI(componentTemplateName)),
},
{
name: i18n.translate('xpack.idxMgmt.componentTemplateDetails.deleteButtonLabel', {
defaultMessage: 'Delete',
}),
icon: 'trash',
getIsDisabled: (details: ComponentTemplateDeserialized) =>
details._kbnMeta.usedBy.length > 0,
closePopoverOnClick: true,
handleActionClick: () => {
setComponentTemplatesToDelete([attemptToDecodeURI(componentTemplateName)]);
},
},
]}
/>
)}
</div>
);
};

View file

@ -11,8 +11,12 @@ import { FormattedMessage } from '@kbn/i18n/react';
import { i18n } from '@kbn/i18n';
import { ComponentTemplateListItem } from '../../../../../common';
import { SectionError, SectionLoading } from '../shared_imports';
import { ComponentTemplateDetailsFlyout } from '../component_template_details';
import { SectionError, SectionLoading, GlobalFlyout } from '../shared_imports';
import {
ComponentTemplateDetailsFlyoutContent,
defaultFlyoutProps,
ComponentTemplateDetailsProps,
} from '../component_template_details';
import { CreateButtonPopOver } from './components';
import { ComponentTemplates } from './component_templates';
import { ComponentTemplatesSelection } from './component_templates_selection';
@ -20,10 +24,12 @@ import { useApi } from '../component_templates_context';
import './component_templates_selector.scss';
const { useGlobalFlyout } = GlobalFlyout;
interface Props {
onChange: (components: string[]) => void;
onComponentsLoaded: (components: ComponentTemplateListItem[]) => void;
defaultValue: string[];
defaultValue?: string[];
docUri: string;
emptyPrompt?: {
text?: string | JSX.Element;
@ -53,6 +59,10 @@ export const ComponentTemplatesSelector = ({
emptyPrompt: { text, showCreateButton } = {},
}: Props) => {
const { data: components, isLoading, error } = useApi().useLoadComponentTemplates();
const {
addContent: addContentToGlobalFlyout,
removeContent: removeContentFromGlobalFlyout,
} = useGlobalFlyout();
const [selectedComponent, setSelectedComponent] = useState<string | null>(null);
const [componentsSelected, setComponentsSelected] = useState<ComponentTemplateListItem[]>([]);
const isInitialized = useRef(false);
@ -60,15 +70,20 @@ export const ComponentTemplatesSelector = ({
const hasSelection = Object.keys(componentsSelected).length > 0;
const hasComponents = components && components.length > 0 ? true : false;
const closeComponentTemplateDetails = () => {
setSelectedComponent(null);
};
useEffect(() => {
if (components) {
if (
defaultValue &&
defaultValue.length > 0 &&
componentsSelected.length === 0 &&
isInitialized.current === false
) {
// Once the components are loaded we check the ones selected
// from the defaultValue provided
// Once the components are fetched, we check the ones previously selected
// from the prop "defaultValue" passed.
const nextComponentsSelected = defaultValue
.map((name) => components.find((comp) => comp.name === name))
.filter(Boolean) as ComponentTemplateListItem[];
@ -88,6 +103,30 @@ export const ComponentTemplatesSelector = ({
}
}, [isLoading, error, components, onComponentsLoaded]);
useEffect(() => {
if (selectedComponent) {
// Open the flyout with the Component Template Details content
addContentToGlobalFlyout<ComponentTemplateDetailsProps>({
id: 'componentTemplateDetails',
Component: ComponentTemplateDetailsFlyoutContent,
props: {
onClose: closeComponentTemplateDetails,
componentTemplateName: selectedComponent,
},
flyoutProps: { ...defaultFlyoutProps, onClose: closeComponentTemplateDetails },
cleanUpFunc: () => {
setSelectedComponent(null);
},
});
}
}, [selectedComponent, addContentToGlobalFlyout]);
useEffect(() => {
if (!selectedComponent) {
removeContentFromGlobalFlyout('componentTemplateDetails');
}
}, [selectedComponent, removeContentFromGlobalFlyout]);
const onSelectionReorder = (reorderedComponents: ComponentTemplateListItem[]) => {
setComponentsSelected(reorderedComponents);
};
@ -198,30 +237,12 @@ export const ComponentTemplatesSelector = ({
</EuiFlexGroup>
);
const renderComponentDetails = () => {
if (!selectedComponent) {
return null;
}
return (
<ComponentTemplateDetailsFlyout
onClose={() => setSelectedComponent(null)}
componentTemplateName={selectedComponent}
/>
);
};
if (isLoading) {
return renderLoading();
} else if (error) {
return renderError();
} else if (hasComponents) {
return (
<>
{renderSelector()}
{renderComponentDetails()}
</>
);
return renderSelector();
}
// No components: render empty prompt
@ -244,6 +265,7 @@ export const ComponentTemplatesSelector = ({
</p>
</EuiText>
);
return (
<EuiEmptyPrompt
iconType="managementApp"

View file

@ -8,7 +8,10 @@ export { ComponentTemplatesProvider } from './component_templates_context';
export { ComponentTemplateList } from './component_template_list';
export { ComponentTemplateDetailsFlyout } from './component_template_details';
export {
ComponentTemplateDetailsFlyoutContent,
defaultFlyoutProps as componentDetailsFlyoutProps,
} from './component_template_details';
export {
ComponentTemplateCreate,

View file

@ -19,6 +19,7 @@ export {
useAuthorizationContext,
NotAuthorizedSection,
Forms,
GlobalFlyout,
} from '../../../../../../../src/plugins/es_ui_shared/public';
export {

View file

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

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 * from './simulate_template';

View file

@ -0,0 +1,13 @@
/*
* 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 {
SimulateTemplateFlyoutContent,
defaultFlyoutProps as simulateTemplateFlyoutProps,
Props as SimulateTemplateProps,
} from './simulate_template_flyout';
export { SimulateTemplate } from './simulate_template';

View file

@ -0,0 +1,60 @@
/*
* 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, useCallback, useEffect } from 'react';
import uuid from 'uuid';
import { EuiCodeBlock } from '@elastic/eui';
import { serializers } from '../../../../shared_imports';
import { TemplateDeserialized } from '../../../../../common';
import { serializeTemplate } from '../../../../../common/lib/template_serialization';
import { simulateIndexTemplate } from '../../../services';
const { stripEmptyFields } = serializers;
interface Props {
template: { [key: string]: any };
minHeightCodeBlock?: string;
}
export const SimulateTemplate = React.memo(({ template, minHeightCodeBlock }: Props) => {
const [templatePreview, setTemplatePreview] = useState('{}');
const updatePreview = useCallback(async () => {
if (!template || Object.keys(template).length === 0) {
return;
}
const indexTemplate = serializeTemplate(stripEmptyFields(template) as TemplateDeserialized);
// Until ES fixes a bug on their side we will send a random index pattern to the simulate API.
// Issue: https://github.com/elastic/elasticsearch/issues/59152
indexTemplate.index_patterns = [uuid.v4()];
const { data, error } = await simulateIndexTemplate(indexTemplate);
if (data) {
// "Overlapping" info is only useful when simulating against an index
// which we don't do here.
delete data.overlapping;
}
setTemplatePreview(JSON.stringify(data ?? error, null, 2));
}, [template]);
useEffect(() => {
updatePreview();
}, [updatePreview]);
return templatePreview === '{}' ? null : (
<EuiCodeBlock
style={{ minHeight: minHeightCodeBlock }}
lang="json"
data-test-subj="simulateTemplatePreview"
>
{templatePreview}
</EuiCodeBlock>
);
});

View file

@ -0,0 +1,119 @@
/*
* 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, useCallback, useEffect, useRef } from 'react';
import { FormattedMessage } from '@kbn/i18n/react';
import {
EuiFlyoutHeader,
EuiTitle,
EuiFlyoutBody,
EuiFlyoutFooter,
EuiFlexGroup,
EuiFlexItem,
EuiButton,
EuiButtonEmpty,
EuiTextColor,
EuiText,
EuiSpacer,
} from '@elastic/eui';
import { SimulateTemplate } from './simulate_template';
export interface Props {
onClose(): void;
getTemplate: () => { [key: string]: any };
}
export const defaultFlyoutProps = {
'data-test-subj': 'simulateTemplateFlyout',
'aria-labelledby': 'simulateTemplateFlyoutTitle',
};
export const SimulateTemplateFlyoutContent = ({ onClose, getTemplate }: Props) => {
const isMounted = useRef(false);
const [heightCodeBlock, setHeightCodeBlock] = useState(0);
const [template, setTemplate] = useState<{ [key: string]: any }>({});
useEffect(() => {
setHeightCodeBlock(
document.getElementsByClassName('euiFlyoutBody__overflow')[0].clientHeight - 96
);
}, []);
const updatePreview = useCallback(async () => {
const indexTemplate = await getTemplate();
setTemplate(indexTemplate);
}, [getTemplate]);
useEffect(() => {
if (isMounted.current === false) {
updatePreview();
}
isMounted.current = true;
}, [updatePreview]);
return (
<>
<EuiFlyoutHeader>
<EuiTitle size="m">
<h2 id="componentTemplatesFlyoutTitle" data-test-subj="title">
<FormattedMessage
id="xpack.idxMgmt.simulateTemplate.title"
defaultMessage="Preview index template"
/>
</h2>
</EuiTitle>
<EuiSpacer size="xs" />
<EuiTextColor color="subdued">
<EuiText size="s">
<p>
<FormattedMessage
id="xpack.idxMgmt.simulateTemplate.descriptionText"
defaultMessage="This is the final template that will be applied to your indices based on the
components templates you have selected and any overrides you've added."
/>
</p>
</EuiText>
</EuiTextColor>
</EuiFlyoutHeader>
<EuiFlyoutBody data-test-subj="content">
<SimulateTemplate template={template} minHeightCodeBlock={`${heightCodeBlock}px`} />
</EuiFlyoutBody>
<EuiFlyoutFooter>
<EuiFlexGroup justifyContent="spaceBetween" alignItems="center">
<EuiFlexItem grow={false}>
<EuiButtonEmpty
iconType="cross"
flush="left"
onClick={onClose}
data-test-subj="closeDetailsButton"
>
<FormattedMessage
id="xpack.idxMgmt.simulateTemplate.closeButtonLabel"
defaultMessage="Close"
/>
</EuiButtonEmpty>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButton
iconType="refresh"
onClick={updatePreview}
data-test-subj="updateSimulationButton"
fill
>
<FormattedMessage
id="xpack.idxMgmt.simulateTemplate.updateButtonLabel"
defaultMessage="Update"
/>
</EuiButton>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlyoutFooter>
</>
);
};

View file

@ -36,8 +36,6 @@ describe('Mappings editor: shape datatype', () => {
test('initial view and default parameters values', async () => {
const defaultMappings = {
_meta: {},
_source: {},
properties: {
myField: {
type: 'shape',

View file

@ -47,8 +47,6 @@ describe.skip('Mappings editor: text datatype', () => {
test('initial view and default parameters values', async () => {
const defaultMappings = {
_meta: {},
_source: {},
properties: {
myField: {
type: 'text',

View file

@ -65,8 +65,6 @@ describe('Mappings editor: edit field', () => {
test('should update form parameters when changing the field datatype', async () => {
const defaultMappings = {
_meta: {},
_source: {},
properties: {
userName: {
...defaultTextParameters,

View file

@ -7,9 +7,11 @@ import React from 'react';
import { act } from 'react-dom/test-utils';
import { ReactWrapper } from 'enzyme';
import { GlobalFlyout } from '../../../../../../../../../../src/plugins/es_ui_shared/public';
import { registerTestBed, TestBed } from '../../../../../../../../../test_utils';
import { getChildFieldsName } from '../../../lib';
import { MappingsEditor } from '../../../mappings_editor';
import { MappingsEditorProvider } from '../../../mappings_editor_context';
jest.mock('@elastic/eui', () => {
const original = jest.requireActual('@elastic/eui');
@ -51,6 +53,8 @@ jest.mock('@elastic/eui', () => {
};
});
const { GlobalFlyoutProvider } = GlobalFlyout;
export interface DomFields {
[key: string]: {
type: string;
@ -247,7 +251,15 @@ const createActions = (testBed: TestBed<TestSubjects>) => {
};
export const setup = (props: any = { onUpdate() {} }): MappingsEditorTestBed => {
const setupTestBed = registerTestBed<TestSubjects>(MappingsEditor, {
const ComponentToTest = (propsOverride: { [key: string]: any }) => (
<MappingsEditorProvider>
<GlobalFlyoutProvider>
<MappingsEditor {...props} {...propsOverride} />
</GlobalFlyoutProvider>
</MappingsEditorProvider>
);
const setupTestBed = registerTestBed<TestSubjects>(ComponentToTest, {
memoryRouter: {
wrapComponent: false,
},

View file

@ -7,16 +7,14 @@ import React, { useEffect, useRef } from 'react';
import { EuiSpacer } from '@elastic/eui';
import { useForm, Form, SerializerFunc } from '../../shared_imports';
import { GenericObject } from '../../types';
import { Types, useDispatch } from '../../mappings_state';
import { GenericObject, MappingsConfiguration } from '../../types';
import { useDispatch } from '../../mappings_state_context';
import { DynamicMappingSection } from './dynamic_mapping_section';
import { SourceFieldSection } from './source_field_section';
import { MetaFieldSection } from './meta_field_section';
import { RoutingSection } from './routing_section';
import { configurationFormSchema } from './configuration_form_schema';
type MappingsConfiguration = Types['MappingsConfiguration'];
interface Props {
value?: MappingsConfiguration;
}

View file

@ -11,8 +11,7 @@ import { EuiLink, EuiCode } from '@elastic/eui';
import { documentationService } from '../../../../services/documentation';
import { FormSchema, FIELD_TYPES, VALIDATION_TYPES, fieldValidators } from '../../shared_imports';
import { MappingsConfiguration } from '../../reducer';
import { ComboBoxOption } from '../../types';
import { ComboBoxOption, MappingsConfiguration } from '../../types';
const { containsCharsField, isJsonField } = fieldValidators;

View file

@ -6,7 +6,7 @@
import React, { useMemo, useCallback } from 'react';
import { EuiSpacer } from '@elastic/eui';
import { useMappingsState, useDispatch } from '../../mappings_state';
import { useMappingsState, useDispatch } from '../../mappings_state_context';
import { deNormalize } from '../../lib';
import { EditFieldContainer } from './fields';
import { DocumentFieldsHeader } from './document_fields_header';
@ -18,7 +18,7 @@ export const DocumentFields = React.memo(() => {
const { fields, search, documentFields } = useMappingsState();
const dispatch = useDispatch();
const { status, fieldToEdit, editor: editorType } = documentFields;
const { editor: editorType } = documentFields;
const jsonEditorDefaultValue = useMemo(() => {
if (editorType === 'json') {
@ -33,14 +33,6 @@ export const DocumentFields = React.memo(() => {
<DocumentFieldsTreeEditor />
);
const renderEditField = () => {
if (status !== 'editingField') {
return null;
}
const field = fields.byId[fieldToEdit!];
return <EditFieldContainer field={field} allFields={fields.byId} />;
};
const onSearchChange = useCallback(
(value: string) => {
dispatch({ type: 'search:update', value });
@ -59,7 +51,7 @@ export const DocumentFields = React.memo(() => {
) : (
editor
)}
{renderEditField()}
<EditFieldContainer />
</div>
);
});

View file

@ -7,7 +7,7 @@
import React from 'react';
import { EuiButton, EuiText } from '@elastic/eui';
import { useDispatch, useMappingsState } from '../../mappings_state';
import { useDispatch, useMappingsState } from '../../mappings_state_context';
import { FieldsEditor } from '../../types';
import { canUseMappingsEditor, normalize } from '../../lib';

View file

@ -9,7 +9,7 @@ import React from 'react';
import { TextField, UseField, FieldConfig } from '../../../shared_imports';
import { validateUniqueName } from '../../../lib';
import { PARAMETERS_DEFINITION } from '../../../constants';
import { useMappingsState } from '../../../mappings_state';
import { useMappingsState } from '../../../mappings_state_context';
export const NameParameter = () => {
const {

View file

@ -70,7 +70,13 @@ export const TypeParameter = ({ isMultiField, isRootLevelField, showDocLink = fa
: filterTypesForNonRootFields(FIELD_TYPES_OPTIONS)
}
selectedOptions={typeField.value}
onChange={typeField.setValue}
onChange={(value) => {
if (value.length === 0) {
// Don't allow clearing the type. One must always be selected
return;
}
typeField.setValue(value);
}}
isClearable={false}
data-test-subj="fieldType"
/>

View file

@ -18,7 +18,7 @@ import {
import { useForm, Form, FormDataProvider } from '../../../../shared_imports';
import { EUI_SIZE } from '../../../../constants';
import { useDispatch } from '../../../../mappings_state';
import { useDispatch } from '../../../../mappings_state_context';
import { fieldSerializer } from '../../../../lib';
import { Field, NormalizedFields } from '../../../../types';
import { NameParameter, TypeParameter, SubTypeParameter } from '../../field_parameters';

View file

@ -7,7 +7,7 @@
import React, { useState } from 'react';
import { i18n } from '@kbn/i18n';
import { useMappingsState, useDispatch } from '../../../mappings_state';
import { useMappingsState, useDispatch } from '../../../mappings_state_context';
import { NormalizedField } from '../../../types';
import { getAllDescendantAliases } from '../../../lib';
import { ModalConfirmationDeleteFields } from './modal_confirmation_delete_fields';

View file

@ -6,7 +6,6 @@
import React from 'react';
import { i18n } from '@kbn/i18n';
import {
EuiFlyout,
EuiFlyoutHeader,
EuiFlyoutBody,
EuiFlyoutFooter,
@ -25,7 +24,7 @@ import { TYPE_DEFINITION } from '../../../../constants';
import { Field, NormalizedField, NormalizedFields, MainType, SubType } from '../../../../types';
import { CodeBlock } from '../../../code_block';
import { getParametersFormForType } from '../field_types';
import { UpdateFieldProvider, UpdateFieldFunc } from './update_field_provider';
import { UpdateFieldFunc } from './use_update_field';
import { EditFieldHeaderForm } from './edit_field_header_form';
const limitStringLength = (text: string, limit = 18): string => {
@ -36,19 +35,28 @@ const limitStringLength = (text: string, limit = 18): string => {
return `...${text.substr(limit * -1)}`;
};
interface Props {
export interface Props {
form: FormHook<Field>;
field: NormalizedField;
allFields: NormalizedFields['byId'];
exitEdit(): void;
updateField: UpdateFieldFunc;
}
export const EditField = React.memo(({ form, field, allFields, exitEdit }: Props) => {
const getSubmitForm = (updateField: UpdateFieldFunc) => async (e?: React.FormEvent) => {
if (e) {
e.preventDefault();
}
export const defaultFlyoutProps = {
'data-test-subj': 'mappingsEditorFieldEdit',
'aria-labelledby': 'mappingsEditorFieldEditTitle',
className: 'mappingsEditor__editField',
maxWidth: 720,
};
// The default FormWrapper is the <EuiForm />, which wrapps the form with
// a <div>. We can't have a div as first child of the Flyout as it breaks
// the height calculaction and does not render the footer position correctly.
const FormWrapper: React.FC = ({ children }) => <>{children}</>;
export const EditField = React.memo(({ form, field, allFields, exitEdit, updateField }: Props) => {
const submitForm = async () => {
const { isValid, data } = await form.submit();
if (isValid) {
@ -56,174 +64,152 @@ export const EditField = React.memo(({ form, field, allFields, exitEdit }: Props
}
};
const cancel = () => {
exitEdit();
};
const { isMultiField } = field;
return (
<UpdateFieldProvider>
{(updateField) => (
<Form form={form} onSubmit={getSubmitForm(updateField)}>
<EuiFlyout
data-test-subj="mappingsEditorFieldEdit"
onClose={exitEdit}
aria-labelledby="mappingsEditorFieldEditTitle"
size="m"
className="mappingsEditor__editField"
maxWidth={720}
>
<EuiFlyoutHeader>
<EuiFlexGroup gutterSize="xs">
<EuiFlexItem>
{/* We need an extra div to get out of flex grow */}
<div>
{/* Title */}
<EuiTitle size="m">
<h2 data-test-subj="flyoutTitle">
{isMultiField
? i18n.translate('xpack.idxMgmt.mappingsEditor.editMultiFieldTitle', {
defaultMessage: "Edit multi-field '{fieldName}'",
values: {
fieldName: limitStringLength(field.source.name),
},
})
: i18n.translate('xpack.idxMgmt.mappingsEditor.editFieldTitle', {
defaultMessage: "Edit field '{fieldName}'",
values: {
fieldName: limitStringLength(field.source.name),
},
})}
</h2>
</EuiTitle>
</div>
</EuiFlexItem>
<Form form={form} FormWrapper={FormWrapper}>
<EuiFlyoutHeader>
<EuiFlexGroup gutterSize="xs">
<EuiFlexItem>
{/* We need an extra div to get out of flex grow */}
<div>
{/* Title */}
<EuiTitle size="m">
<h2 data-test-subj="flyoutTitle">
{isMultiField
? i18n.translate('xpack.idxMgmt.mappingsEditor.editMultiFieldTitle', {
defaultMessage: "Edit multi-field '{fieldName}'",
values: {
fieldName: limitStringLength(field.source.name),
},
})
: i18n.translate('xpack.idxMgmt.mappingsEditor.editFieldTitle', {
defaultMessage: "Edit field '{fieldName}'",
values: {
fieldName: limitStringLength(field.source.name),
},
})}
</h2>
</EuiTitle>
</div>
</EuiFlexItem>
{/* Documentation link */}
<FormDataProvider pathsToWatch={['type', 'subType']}>
{({ type, subType }) => {
const linkDocumentation =
documentationService.getTypeDocLink(subType) ||
documentationService.getTypeDocLink(type);
{/* Documentation link */}
<FormDataProvider pathsToWatch={['type', 'subType']}>
{({ type, subType }) => {
const linkDocumentation =
documentationService.getTypeDocLink(subType) ||
documentationService.getTypeDocLink(type);
if (!linkDocumentation) {
return null;
}
if (!linkDocumentation) {
return null;
}
const typeDefinition = TYPE_DEFINITION[type as MainType];
const subTypeDefinition = TYPE_DEFINITION[subType as SubType];
const typeDefinition = TYPE_DEFINITION[type as MainType];
const subTypeDefinition = TYPE_DEFINITION[subType as SubType];
return (
<EuiFlexItem grow={false}>
<EuiButtonEmpty
size="s"
flush="right"
href={linkDocumentation}
target="_blank"
iconType="help"
data-test-subj="documentationLink"
>
{i18n.translate(
'xpack.idxMgmt.mappingsEditor.editField.typeDocumentation',
{
defaultMessage: '{type} documentation',
values: {
type: subTypeDefinition
? subTypeDefinition.label
: typeDefinition.label,
},
}
)}
</EuiButtonEmpty>
</EuiFlexItem>
);
}}
</FormDataProvider>
</EuiFlexGroup>
{/* Field path */}
<EuiFlexGroup>
<EuiFlexItem grow={false} data-test-subj="fieldPath">
<CodeBlock padding="small">{field.path.join(' > ')}</CodeBlock>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlyoutHeader>
<EuiFlyoutBody>
<EditFieldHeaderForm
defaultValue={field.source}
isRootLevelField={field.parentId === undefined}
isMultiField={isMultiField}
/>
<FormDataProvider pathsToWatch={['type', 'subType']}>
{({ type, subType }) => {
const ParametersForm = getParametersFormForType(type, subType);
if (!ParametersForm) {
return null;
}
return (
<ParametersForm
// As the component "ParametersForm" does not change when switching type, and all the props
// also remain the same (===), adding a key give us *a new instance* each time we change the type or subType.
// This will trigger an unmount of all the previous form fields and then mount the new ones.
key={subType ?? type}
field={field}
allFields={allFields}
isMultiField={isMultiField}
/>
);
}}
</FormDataProvider>
</EuiFlyoutBody>
<EuiFlyoutFooter>
{form.isSubmitted && !form.isValid && (
<>
<EuiCallOut
title={i18n.translate(
'xpack.idxMgmt.mappingsEditor.editFieldFlyout.validationErrorTitle',
{
defaultMessage: 'Fix errors in form before continuing.',
}
)}
color="danger"
iconType="cross"
data-test-subj="formError"
/>
<EuiSpacer size="m" />
</>
)}
<EuiFlexGroup justifyContent="flexEnd">
return (
<EuiFlexItem grow={false}>
<EuiButtonEmpty onClick={cancel}>
{i18n.translate('xpack.idxMgmt.mappingsEditor.editFieldCancelButtonLabel', {
defaultMessage: 'Cancel',
<EuiButtonEmpty
size="s"
flush="right"
href={linkDocumentation}
target="_blank"
iconType="help"
data-test-subj="documentationLink"
>
{i18n.translate('xpack.idxMgmt.mappingsEditor.editField.typeDocumentation', {
defaultMessage: '{type} documentation',
values: {
type: subTypeDefinition ? subTypeDefinition.label : typeDefinition.label,
},
})}
</EuiButtonEmpty>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButton
fill
onClick={getSubmitForm(updateField)}
type="submit"
disabled={form.isSubmitted && !form.isValid}
data-test-subj="editFieldUpdateButton"
>
{i18n.translate('xpack.idxMgmt.mappingsEditor.editFieldUpdateButtonLabel', {
defaultMessage: 'Update',
})}
</EuiButton>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlyoutFooter>
</EuiFlyout>
</Form>
)}
</UpdateFieldProvider>
);
}}
</FormDataProvider>
</EuiFlexGroup>
{/* Field path */}
<EuiFlexGroup>
<EuiFlexItem grow={false} data-test-subj="fieldPath">
<CodeBlock padding="small">{field.path.join(' > ')}</CodeBlock>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlyoutHeader>
<EuiFlyoutBody>
<EditFieldHeaderForm
defaultValue={field.source}
isRootLevelField={field.parentId === undefined}
isMultiField={isMultiField}
/>
<FormDataProvider pathsToWatch={['type', 'subType']}>
{({ type, subType }) => {
const ParametersForm = getParametersFormForType(type, subType);
if (!ParametersForm) {
return null;
}
return (
<ParametersForm
// As the component "ParametersForm" does not change when switching type, and all the props
// also remain the same (===), adding a key give us *a new instance* each time we change the type or subType.
// This will trigger an unmount of all the previous form fields and then mount the new ones.
key={subType ?? type}
field={field}
allFields={allFields}
isMultiField={isMultiField}
/>
);
}}
</FormDataProvider>
</EuiFlyoutBody>
<EuiFlyoutFooter>
{form.isSubmitted && !form.isValid && (
<>
<EuiCallOut
title={i18n.translate(
'xpack.idxMgmt.mappingsEditor.editFieldFlyout.validationErrorTitle',
{
defaultMessage: 'Fix errors in form before continuing.',
}
)}
color="danger"
iconType="cross"
data-test-subj="formError"
/>
<EuiSpacer size="m" />
</>
)}
<EuiFlexGroup justifyContent="flexEnd">
<EuiFlexItem grow={false}>
<EuiButtonEmpty onClick={exitEdit}>
{i18n.translate('xpack.idxMgmt.mappingsEditor.editFieldCancelButtonLabel', {
defaultMessage: 'Cancel',
})}
</EuiButtonEmpty>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButton
fill
onClick={submitForm}
type="submit"
disabled={form.isSubmitted && !form.isValid}
data-test-subj="editFieldUpdateButton"
>
{i18n.translate('xpack.idxMgmt.mappingsEditor.editFieldUpdateButtonLabel', {
defaultMessage: 'Update',
})}
</EuiButton>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlyoutFooter>
</Form>
);
});

View file

@ -3,24 +3,38 @@
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React, { useEffect, useCallback } from 'react';
import React, { useEffect, useCallback, useMemo } from 'react';
import { useForm } from '../../../../shared_imports';
import { useDispatch } from '../../../../mappings_state';
import { Field, NormalizedField, NormalizedFields } from '../../../../types';
import { useForm, GlobalFlyout } from '../../../../shared_imports';
import { useDispatch, useMappingsState } from '../../../../mappings_state_context';
import { Field } from '../../../../types';
import { fieldSerializer, fieldDeserializer } from '../../../../lib';
import { EditField } from './edit_field';
import { ModalConfirmationDeleteFields } from '../modal_confirmation_delete_fields';
import { EditField, defaultFlyoutProps, Props as EditFieldProps } from './edit_field';
import { useUpdateField } from './use_update_field';
interface Props {
field: NormalizedField;
allFields: NormalizedFields['byId'];
}
const { useGlobalFlyout } = GlobalFlyout;
export const EditFieldContainer = React.memo(({ field, allFields }: Props) => {
export const EditFieldContainer = React.memo(() => {
const { fields, documentFields } = useMappingsState();
const dispatch = useDispatch();
const {
addContent: addContentToGlobalFlyout,
removeContent: removeContentFromGlobalFlyout,
} = useGlobalFlyout();
const { updateField, modal } = useUpdateField();
const { status, fieldToEdit } = documentFields;
const isEditing = status === 'editingField';
const field = isEditing ? fields.byId[fieldToEdit!] : undefined;
const formDefaultValue = useMemo(() => {
return { ...field?.source };
}, [field?.source]);
const { form } = useForm<Field>({
defaultValue: { ...field.source },
defaultValue: formDefaultValue,
serializer: fieldSerializer,
deserializer: fieldDeserializer,
options: { stripEmptyFields: false },
@ -40,5 +54,48 @@ export const EditFieldContainer = React.memo(({ field, allFields }: Props) => {
dispatch({ type: 'documentField.changeStatus', value: 'idle' });
}, [dispatch]);
return <EditField form={form} field={field} allFields={allFields} exitEdit={exitEdit} />;
useEffect(() => {
if (isEditing) {
// Open the flyout with the <EditField /> content
addContentToGlobalFlyout<EditFieldProps>({
id: 'mappingsEditField',
Component: EditField,
props: {
form,
field: field!,
exitEdit,
allFields: fields.byId,
updateField,
},
flyoutProps: { ...defaultFlyoutProps, onClose: exitEdit },
cleanUpFunc: exitEdit,
});
}
}, [
isEditing,
field,
form,
addContentToGlobalFlyout,
fields.byId,
fieldToEdit,
exitEdit,
updateField,
]);
useEffect(() => {
if (!isEditing) {
removeContentFromGlobalFlyout('mappingsEditField');
}
}, [isEditing, removeContentFromGlobalFlyout]);
useEffect(() => {
return () => {
if (isEditing) {
// When the component unmounts, exit edit mode.
exitEdit();
}
};
}, [isEditing, exitEdit]);
return modal.isOpen ? <ModalConfirmationDeleteFields {...modal.props} /> : null;
});

View file

@ -1,147 +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.
*/
import React, { useState } from 'react';
import { i18n } from '@kbn/i18n';
import { useMappingsState, useDispatch } from '../../../../mappings_state';
import { shouldDeleteChildFieldsAfterTypeChange, getAllDescendantAliases } from '../../../../lib';
import { NormalizedField, DataType } from '../../../../types';
import { PARAMETERS_DEFINITION } from '../../../../constants';
import { ModalConfirmationDeleteFields } from '../modal_confirmation_delete_fields';
export type UpdateFieldFunc = (field: NormalizedField) => void;
interface Props {
children: (saveProperty: UpdateFieldFunc) => React.ReactNode;
}
interface State {
isModalOpen: boolean;
field?: NormalizedField;
aliases?: string[];
}
export const UpdateFieldProvider = ({ children }: Props) => {
const [state, setState] = useState<State>({
isModalOpen: false,
});
const dispatch = useDispatch();
const { fields } = useMappingsState();
const { byId, aliases } = fields;
const confirmButtonText = i18n.translate(
'xpack.idxMgmt.mappingsEditor.updateField.confirmationModal.confirmDescription',
{
defaultMessage: 'Confirm type change',
}
);
let modalTitle: string | undefined;
if (state.field) {
const { source } = state.field;
modalTitle = i18n.translate(
'xpack.idxMgmt.mappingsEditor.updateField.confirmationModal.title',
{
defaultMessage: "Confirm change '{fieldName}' type to '{fieldType}'.",
values: {
fieldName: source.name,
fieldType: source.type,
},
}
);
}
const closeModal = () => {
setState({ isModalOpen: false });
};
const updateField: UpdateFieldFunc = (field) => {
const previousField = byId[field.id];
const willDeleteChildFields = (oldType: DataType, newType: DataType): boolean => {
const { hasChildFields, hasMultiFields } = field;
if (!hasChildFields && !hasMultiFields) {
// No child or multi-fields will be deleted, no confirmation needed.
return false;
}
return shouldDeleteChildFieldsAfterTypeChange(oldType, newType);
};
if (field.source.type !== previousField.source.type) {
// Array of all the aliases pointing to the current field beeing updated
const aliasesOnField = aliases[field.id] || [];
// Array of all the aliases pointing to the current field + all its possible children
const aliasesOnFieldAndDescendants = getAllDescendantAliases(field, fields);
const isReferencedByAlias = aliasesOnField && Boolean(aliasesOnField.length);
const nextTypeCanHaveAlias = !PARAMETERS_DEFINITION.path.targetTypesNotAllowed.includes(
field.source.type
);
// We need to check if, by changing the type, we will also
// delete possible child properties ("fields" or "properties").
// If we will, we need to warn the user about it.
let requiresConfirmation: boolean;
let aliasesToDelete: string[] = [];
if (isReferencedByAlias && !nextTypeCanHaveAlias) {
aliasesToDelete = aliasesOnFieldAndDescendants;
requiresConfirmation = true;
} else {
requiresConfirmation = willDeleteChildFields(previousField.source.type, field.source.type);
if (requiresConfirmation) {
aliasesToDelete = aliasesOnFieldAndDescendants.filter(
// We will only delete aliases that points to possible children, *NOT* the field itself
(id) => aliasesOnField.includes(id) === false
);
}
}
if (requiresConfirmation) {
setState({
isModalOpen: true,
field,
aliases: Boolean(aliasesToDelete.length)
? aliasesToDelete.map((id) => byId[id].path.join(' > ')).sort()
: undefined,
});
return;
}
}
dispatch({ type: 'field.edit', value: field.source });
};
const confirmTypeUpdate = () => {
dispatch({ type: 'field.edit', value: state.field!.source });
closeModal();
};
return (
<>
{children(updateField)}
{state.isModalOpen && (
<ModalConfirmationDeleteFields
title={modalTitle!}
childFields={state.field && state.field.childFields}
aliases={state.aliases}
byId={byId}
confirmButtonText={confirmButtonText}
onConfirm={confirmTypeUpdate}
onCancel={closeModal}
/>
)}
</>
);
};

View file

@ -0,0 +1,146 @@
/*
* 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 { useState, useCallback } from 'react';
import { i18n } from '@kbn/i18n';
import { useMappingsState, useDispatch } from '../../../../mappings_state_context';
import { shouldDeleteChildFieldsAfterTypeChange, getAllDescendantAliases } from '../../../../lib';
import { NormalizedField, DataType } from '../../../../types';
import { PARAMETERS_DEFINITION } from '../../../../constants';
export type UpdateFieldFunc = (field: NormalizedField) => void;
interface State {
isModalOpen: boolean;
field?: NormalizedField;
aliases?: string[];
}
export const useUpdateField = () => {
const [state, setState] = useState<State>({
isModalOpen: false,
});
const dispatch = useDispatch();
const { fields } = useMappingsState();
const { byId, aliases } = fields;
const confirmButtonText = i18n.translate(
'xpack.idxMgmt.mappingsEditor.updateField.confirmationModal.confirmDescription',
{
defaultMessage: 'Confirm type change',
}
);
let modalTitle = '';
if (state.field) {
const { source } = state.field;
modalTitle = i18n.translate(
'xpack.idxMgmt.mappingsEditor.updateField.confirmationModal.title',
{
defaultMessage: "Confirm change '{fieldName}' type to '{fieldType}'.",
values: {
fieldName: source.name,
fieldType: source.type,
},
}
);
}
const closeModal = () => {
setState({ isModalOpen: false });
};
const updateField: UpdateFieldFunc = useCallback(
(field) => {
const previousField = byId[field.id];
const willDeleteChildFields = (oldType: DataType, newType: DataType): boolean => {
const { hasChildFields, hasMultiFields } = field;
if (!hasChildFields && !hasMultiFields) {
// No child or multi-fields will be deleted, no confirmation needed.
return false;
}
return shouldDeleteChildFieldsAfterTypeChange(oldType, newType);
};
if (field.source.type !== previousField.source.type) {
// Array of all the aliases pointing to the current field beeing updated
const aliasesOnField = aliases[field.id] || [];
// Array of all the aliases pointing to the current field + all its possible children
const aliasesOnFieldAndDescendants = getAllDescendantAliases(field, fields);
const isReferencedByAlias = aliasesOnField && Boolean(aliasesOnField.length);
const nextTypeCanHaveAlias = !PARAMETERS_DEFINITION.path.targetTypesNotAllowed.includes(
field.source.type
);
// We need to check if, by changing the type, we will also
// delete possible child properties ("fields" or "properties").
// If we will, we need to warn the user about it.
let requiresConfirmation: boolean;
let aliasesToDelete: string[] = [];
if (isReferencedByAlias && !nextTypeCanHaveAlias) {
aliasesToDelete = aliasesOnFieldAndDescendants;
requiresConfirmation = true;
} else {
requiresConfirmation = willDeleteChildFields(
previousField.source.type,
field.source.type
);
if (requiresConfirmation) {
aliasesToDelete = aliasesOnFieldAndDescendants.filter(
// We will only delete aliases that points to possible children, *NOT* the field itself
(id) => aliasesOnField.includes(id) === false
);
}
}
if (requiresConfirmation) {
setState({
isModalOpen: true,
field,
aliases: Boolean(aliasesToDelete.length)
? aliasesToDelete.map((id) => byId[id].path.join(' > ')).sort()
: undefined,
});
return;
}
}
dispatch({ type: 'field.edit', value: field.source });
},
[dispatch, aliases, fields, byId]
);
const confirmTypeUpdate = () => {
dispatch({ type: 'field.edit', value: state.field!.source });
closeModal();
};
return {
updateField,
modal: {
isOpen: state.isModalOpen,
props: {
childFields: state.field && state.field.childFields,
title: modalTitle,
aliases: state.aliases,
byId,
confirmButtonText,
onConfirm: confirmTypeUpdate,
onCancel: closeModal,
},
},
};
};

View file

@ -5,7 +5,7 @@
*/
import React, { useMemo, useCallback, useRef } from 'react';
import { useMappingsState, useDispatch } from '../../../mappings_state';
import { useMappingsState, useDispatch } from '../../../mappings_state_context';
import { NormalizedField } from '../../../types';
import { FieldsListItem } from './fields_list_item';

View file

@ -6,7 +6,7 @@
import React, { useRef, useCallback } from 'react';
import { useDispatch } from '../../mappings_state';
import { useDispatch } from '../../mappings_state_context';
import { JsonEditor } from '../../shared_imports';
export interface Props {

View file

@ -8,7 +8,7 @@ import React, { useMemo, useCallback } from 'react';
import { EuiButtonEmpty, EuiSpacer } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { useMappingsState, useDispatch } from '../../mappings_state';
import { useMappingsState, useDispatch } from '../../mappings_state_context';
import { FieldsList, CreateField } from './fields';
export const DocumentFieldsTreeEditor = () => {

View file

@ -8,9 +8,8 @@ import VirtualList from 'react-tiny-virtual-list';
import { EuiEmptyPrompt, EuiButton } from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n/react';
import { SearchResult as SearchResultType } from '../../../types';
import { useDispatch } from '../../../mappings_state';
import { State } from '../../../reducer';
import { SearchResult as SearchResultType, State } from '../../../types';
import { useDispatch } from '../../../mappings_state_context';
import { SearchResultItem } from './search_result_item';
interface Props {

View file

@ -10,7 +10,7 @@ import { i18n } from '@kbn/i18n';
import { SearchResult } from '../../../types';
import { TYPE_DEFINITION } from '../../../constants';
import { useDispatch } from '../../../mappings_state';
import { useDispatch } from '../../../mappings_state_context';
import { getTypeLabelFromType } from '../../../lib';
import { DeleteFieldProvider } from '../fields/delete_field_provider';

View file

@ -4,5 +4,5 @@
* you may not use this file except in compliance with the Elastic License.
*/
export * from './load_from_json_button';
export * from './load_mappings_provider';
export { LoadMappingsFromJsonButton } from './load_from_json_button';
export { LoadMappingsProvider } from './load_mappings_provider';

View file

@ -9,12 +9,11 @@ import { FormattedMessage } from '@kbn/i18n/react';
import { EuiText, EuiLink, EuiSpacer } from '@elastic/eui';
import { useForm, Form, SerializerFunc, UseField, JsonEditorField } from '../../shared_imports';
import { Types, useDispatch } from '../../mappings_state';
import { MappingsTemplates } from '../../types';
import { useDispatch } from '../../mappings_state_context';
import { templatesFormSchema } from './templates_form_schema';
import { documentationService } from '../../../../services/documentation';
type MappingsTemplates = Types['MappingsTemplates'];
interface Props {
value?: MappingsTemplates;
}

View file

@ -7,7 +7,7 @@
import { i18n } from '@kbn/i18n';
import { FormSchema, fieldValidators } from '../../shared_imports';
import { MappingsTemplates } from '../../reducer';
import { MappingsTemplates } from '../../types';
const { isJsonField } = fieldValidators;

View file

@ -4,12 +4,12 @@
* you may not use this file except in compliance with the Elastic License.
*/
export * from './mappings_editor';
export { MappingsEditor } from './mappings_editor';
// We export both the button & the load mappings provider
// to give flexibility to the consumer
export * from './components/load_mappings';
export { LoadMappingsFromJsonButton, LoadMappingsProvider } from './components/load_mappings';
export { OnUpdateHandler, Types } from './mappings_state';
export { MappingsEditorProvider } from './mappings_editor_context';
export { IndexSettings } from './types';
export { IndexSettings, OnUpdateHandler } from './types';

View file

@ -4,6 +4,7 @@
* you may not use this file except in compliance with the Elastic License.
*/
import React, { createContext, useContext } from 'react';
import { IndexSettings } from './types';
const IndexSettingsContext = createContext<IndexSettings | undefined>(undefined);

View file

@ -14,24 +14,40 @@ import {
TemplatesForm,
MultipleMappingsWarning,
} from './components';
import { IndexSettings } from './types';
import {
OnUpdateHandler,
IndexSettings,
Field,
Mappings,
MappingsConfiguration,
MappingsTemplates,
} from './types';
import { extractMappingsDefinition } from './lib';
import { State } from './reducer';
import { MappingsState, Props as MappingsStateProps, Types } from './mappings_state';
import { useMappingsState } from './mappings_state_context';
import { useMappingsStateListener } from './use_state_listener';
import { IndexSettingsProvider } from './index_settings_context';
type TabName = 'fields' | 'advanced' | 'templates';
interface MappingsEditorParsedMetadata {
parsedDefaultValue?: {
configuration: MappingsConfiguration;
fields: { [key: string]: Field };
templates: MappingsTemplates;
};
multipleMappingsDeclared: boolean;
}
interface Props {
onChange: MappingsStateProps['onChange'];
onChange: OnUpdateHandler;
value?: { [key: string]: any };
indexSettings?: IndexSettings;
}
type TabName = 'fields' | 'advanced' | 'templates';
export const MappingsEditor = React.memo(({ onChange, value, indexSettings }: Props) => {
const [selectedTab, selectTab] = useState<TabName>('fields');
const { parsedDefaultValue, multipleMappingsDeclared } = useMemo(() => {
const { parsedDefaultValue, multipleMappingsDeclared } = useMemo<
MappingsEditorParsedMetadata
>(() => {
const mappingsDefinition = extractMappingsDefinition(value);
if (mappingsDefinition === null) {
@ -69,18 +85,28 @@ export const MappingsEditor = React.memo(({ onChange, value, indexSettings }: Pr
return { parsedDefaultValue: parsed, multipleMappingsDeclared: false };
}, [value]);
/**
* Hook that will listen to:
* 1. "value" prop changes in order to reset the mappings editor
* 2. "state" changes in order to communicate any updates to the consumer
*/
useMappingsStateListener({ onChange, value: parsedDefaultValue });
const state = useMappingsState();
const [selectedTab, selectTab] = useState<TabName>('fields');
useEffect(() => {
if (multipleMappingsDeclared) {
// We set the data getter here as the user won't be able to make any changes
onChange({
getData: () => value! as Types['Mappings'],
getData: () => value! as Mappings,
validate: () => Promise.resolve(true),
isValid: true,
});
}
}, [multipleMappingsDeclared, onChange, value]);
const changeTab = async (tab: TabName, state: State) => {
const changeTab = async (tab: TabName) => {
if (selectedTab === 'advanced') {
// When we navigate away we need to submit the form to validate if there are any errors.
const { isValid: isConfigurationFormValid } = await state.configuration.submitForm!();
@ -102,59 +128,53 @@ export const MappingsEditor = React.memo(({ onChange, value, indexSettings }: Pr
selectTab(tab);
};
const tabToContentMap = {
fields: <DocumentFields />,
templates: <TemplatesForm value={state.templates.defaultValue} />,
advanced: <ConfigurationForm value={state.configuration.defaultValue} />,
};
return (
<div data-test-subj="mappingsEditor">
{multipleMappingsDeclared ? (
<MultipleMappingsWarning />
) : (
<IndexSettingsProvider indexSettings={indexSettings}>
<MappingsState onChange={onChange} value={parsedDefaultValue!}>
{({ state }) => {
const tabToContentMap = {
fields: <DocumentFields />,
templates: <TemplatesForm value={state.templates.defaultValue} />,
advanced: <ConfigurationForm value={state.configuration.defaultValue} />,
};
<div className="mappingsEditor">
<EuiTabs>
<EuiTab
onClick={() => changeTab('fields')}
isSelected={selectedTab === 'fields'}
data-test-subj="formTab"
>
{i18n.translate('xpack.idxMgmt.mappingsEditor.fieldsTabLabel', {
defaultMessage: 'Mapped fields',
})}
</EuiTab>
<EuiTab
onClick={() => changeTab('templates')}
isSelected={selectedTab === 'templates'}
data-test-subj="formTab"
>
{i18n.translate('xpack.idxMgmt.mappingsEditor.templatesTabLabel', {
defaultMessage: 'Dynamic templates',
})}
</EuiTab>
<EuiTab
onClick={() => changeTab('advanced')}
isSelected={selectedTab === 'advanced'}
data-test-subj="formTab"
>
{i18n.translate('xpack.idxMgmt.mappingsEditor.advancedTabLabel', {
defaultMessage: 'Advanced options',
})}
</EuiTab>
</EuiTabs>
return (
<div className="mappingsEditor">
<EuiTabs>
<EuiTab
onClick={() => changeTab('fields', state)}
isSelected={selectedTab === 'fields'}
data-test-subj="formTab"
>
{i18n.translate('xpack.idxMgmt.mappingsEditor.fieldsTabLabel', {
defaultMessage: 'Mapped fields',
})}
</EuiTab>
<EuiTab
onClick={() => changeTab('templates', state)}
isSelected={selectedTab === 'templates'}
data-test-subj="formTab"
>
{i18n.translate('xpack.idxMgmt.mappingsEditor.templatesTabLabel', {
defaultMessage: 'Dynamic templates',
})}
</EuiTab>
<EuiTab
onClick={() => changeTab('advanced', state)}
isSelected={selectedTab === 'advanced'}
data-test-subj="formTab"
>
{i18n.translate('xpack.idxMgmt.mappingsEditor.advancedTabLabel', {
defaultMessage: 'Advanced options',
})}
</EuiTab>
</EuiTabs>
<EuiSpacer size="l" />
<EuiSpacer size="l" />
{tabToContentMap[selectedTab]}
</div>
);
}}
</MappingsState>
{tabToContentMap[selectedTab]}
</div>
</IndexSettingsProvider>
)}
</div>

View file

@ -0,0 +1,12 @@
/*
* 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 { StateProvider } from './mappings_state_context';
export const MappingsEditorProvider: React.FC = ({ children }) => {
return <StateProvider>{children}</StateProvider>;
};

View file

@ -0,0 +1,77 @@
/*
* 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, { useReducer, createContext, useContext } from 'react';
import { reducer } from './reducer';
import { State, Dispatch } from './types';
const StateContext = createContext<State | undefined>(undefined);
const DispatchContext = createContext<Dispatch | undefined>(undefined);
export const StateProvider: React.FC = ({ children }) => {
const initialState: State = {
isValid: true,
configuration: {
defaultValue: {},
data: {
raw: {},
format: () => ({}),
},
validate: () => Promise.resolve(true),
},
templates: {
defaultValue: {},
data: {
raw: {},
format: () => ({}),
},
validate: () => Promise.resolve(true),
},
fields: {
byId: {},
rootLevelFields: [],
aliases: {},
maxNestedDepth: 0,
},
documentFields: {
status: 'idle',
editor: 'default',
},
fieldsJsonEditor: {
format: () => ({}),
isValid: true,
},
search: {
term: '',
result: [],
},
};
const [state, dispatch] = useReducer(reducer, initialState);
return (
<StateContext.Provider value={state}>
<DispatchContext.Provider value={dispatch}>{children}</DispatchContext.Provider>
</StateContext.Provider>
);
};
export const useMappingsState = () => {
const ctx = useContext(StateContext);
if (ctx === undefined) {
throw new Error('useMappingsState must be used within a <MappingsState>');
}
return ctx;
};
export const useDispatch = () => {
const ctx = useContext(DispatchContext);
if (ctx === undefined) {
throw new Error('useDispatch must be used within a <MappingsState>');
}
return ctx;
};

View file

@ -3,8 +3,7 @@
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { OnFormUpdateArg, FormHook } from './shared_imports';
import { Field, NormalizedFields, NormalizedField, FieldsEditor, SearchResult } from './types';
import { Field, NormalizedFields, NormalizedField, State, Action } from './types';
import {
getFieldMeta,
getUniqueId,
@ -17,99 +16,6 @@ import {
} from './lib';
import { PARAMETERS_DEFINITION } from './constants';
export interface MappingsConfiguration {
enabled?: boolean;
throwErrorsForUnmappedFields?: boolean;
date_detection: boolean;
numeric_detection: boolean;
dynamic_date_formats: string[];
_source: {
enabled?: boolean;
includes?: string[];
excludes?: string[];
};
_meta?: string;
}
export interface MappingsTemplates {
dynamic_templates: DynamicTemplate[];
}
interface DynamicTemplate {
[key: string]: {
mapping: {
[key: string]: any;
};
match_mapping_type?: string;
match?: string;
unmatch?: string;
match_pattern?: string;
path_match?: string;
path_unmatch?: string;
};
}
export interface MappingsFields {
[key: string]: any;
}
type DocumentFieldsStatus = 'idle' | 'editingField' | 'creatingField';
interface DocumentFieldsState {
status: DocumentFieldsStatus;
editor: FieldsEditor;
fieldToEdit?: string;
fieldToAddFieldTo?: string;
}
interface ConfigurationFormState extends OnFormUpdateArg<MappingsConfiguration> {
defaultValue: MappingsConfiguration;
submitForm?: FormHook<MappingsConfiguration>['submit'];
}
interface TemplatesFormState extends OnFormUpdateArg<MappingsTemplates> {
defaultValue: MappingsTemplates;
submitForm?: FormHook<MappingsTemplates>['submit'];
}
export interface State {
isValid: boolean | undefined;
configuration: ConfigurationFormState;
documentFields: DocumentFieldsState;
fields: NormalizedFields;
fieldForm?: OnFormUpdateArg<any>;
fieldsJsonEditor: {
format(): MappingsFields;
isValid: boolean;
};
search: {
term: string;
result: SearchResult[];
};
templates: TemplatesFormState;
}
export type Action =
| { type: 'editor.replaceMappings'; value: { [key: string]: any } }
| { type: 'configuration.update'; value: Partial<ConfigurationFormState> }
| { type: 'configuration.save'; value: MappingsConfiguration }
| { type: 'templates.update'; value: Partial<State['templates']> }
| { type: 'templates.save'; value: MappingsTemplates }
| { type: 'fieldForm.update'; value: OnFormUpdateArg<any> }
| { type: 'field.add'; value: Field }
| { type: 'field.remove'; value: string }
| { type: 'field.edit'; value: Field }
| { type: 'field.toggleExpand'; value: { fieldId: string; isExpanded?: boolean } }
| { type: 'documentField.createField'; value?: string }
| { type: 'documentField.editField'; value: string }
| { type: 'documentField.changeStatus'; value: DocumentFieldsStatus }
| { type: 'documentField.changeEditor'; value: FieldsEditor }
| { type: 'fieldsJsonEditor.update'; value: { json: { [key: string]: any }; isValid: boolean } }
| { type: 'search:update'; value: string }
| { type: 'validity:update'; value: boolean };
export type Dispatch = (action: Action) => void;
export const addFieldToState = (field: Field, state: State): State => {
const updatedFields = { ...state.fields };
const id = getUniqueId();
@ -277,7 +183,7 @@ export const reducer = (state: State, action: Action): State => {
},
documentFields: {
...state.documentFields,
status: 'idle',
...action.value.documentFields,
fieldToAddFieldTo: undefined,
fieldToEdit: undefined,
},

View file

@ -49,4 +49,5 @@ export {
export {
JsonEditor,
OnJsonEditorUpdateHandler,
GlobalFlyout,
} from '../../../../../../../src/plugins/es_ui_shared/public';

View file

@ -3,10 +3,12 @@
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { ReactNode, OptionHTMLAttributes } from 'react';
import { ReactNode } from 'react';
import { FieldConfig } from './shared_imports';
import { PARAMETERS_DEFINITION } from './constants';
import { GenericObject } from './mappings_editor';
import { FieldConfig } from '../shared_imports';
import { PARAMETERS_DEFINITION } from '../constants';
export interface DataTypeDefinition {
label: string;
@ -203,100 +205,7 @@ export interface NormalizedField extends FieldMeta {
export type ChildFieldName = 'properties' | 'fields';
export type FieldsEditor = 'default' | 'json';
export type SelectOption<T extends string = string> = {
value: unknown;
text: T | ReactNode;
} & OptionHTMLAttributes<HTMLOptionElement>;
export interface SuperSelectOption {
value: unknown;
inputDisplay?: ReactNode;
dropdownDisplay?: ReactNode;
disabled?: boolean;
'data-test-subj'?: string;
}
export interface AliasOption {
id: string;
label: string;
}
export interface IndexSettingsInterface {
analysis?: {
analyzer: {
[key: string]: {
type: string;
tokenizer: string;
char_filter?: string[];
filter?: string[];
position_increment_gap?: number;
};
};
};
}
/**
* When we define the index settings we can skip
* the "index" property and directly add the "analysis".
* ES always returns the settings wrapped under "index".
*/
export type IndexSettings = IndexSettingsInterface | { index: IndexSettingsInterface };
export interface ComboBoxOption {
label: string;
value?: unknown;
}
export interface SearchResult {
display: JSX.Element;
field: NormalizedField;
}
export interface SearchMetadata {
/**
* Whether or not the search term match some part of the field path.
*/
matchPath: boolean;
/**
* If the search term matches the field type we will give it a higher score.
*/
matchType: boolean;
/**
* If the last word of the search terms matches the field name
*/
matchFieldName: boolean;
/**
* If the search term matches the beginning of the path we will give it a higher score
*/
matchStartOfPath: boolean;
/**
* If the last word of the search terms fully matches the field name
*/
fullyMatchFieldName: boolean;
/**
* If the search term exactly matches the field type
*/
fullyMatchType: boolean;
/**
* If the search term matches the full field path
*/
fullyMatchPath: boolean;
/**
* The score of the result that will allow us to sort the list
*/
score: number;
/**
* The JSX with <strong> tag wrapping the matched string
*/
display: JSX.Element;
/**
* The field path substring that matches the search
*/
stringMatch: string | null;
}
export interface GenericObject {
[key: string]: any;
}

View file

@ -0,0 +1,11 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
export * from './mappings_editor';
export * from './document_fields';
export * from './state';

View file

@ -0,0 +1,110 @@
/*
* 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 { ReactNode, OptionHTMLAttributes } from 'react';
import { NormalizedField } from './document_fields';
import { Mappings } from './state';
export type OnUpdateHandler = (arg: OnUpdateHandlerArg) => void;
export interface OnUpdateHandlerArg {
isValid?: boolean;
getData: () => Mappings | undefined;
validate: () => Promise<boolean>;
}
export type FieldsEditor = 'default' | 'json';
export interface IndexSettingsInterface {
analysis?: {
analyzer: {
[key: string]: {
type: string;
tokenizer: string;
char_filter?: string[];
filter?: string[];
position_increment_gap?: number;
};
};
};
}
/**
* When we define the index settings we can skip
* the "index" property and directly add the "analysis".
* ES always returns the settings wrapped under "index".
*/
export type IndexSettings = IndexSettingsInterface | { index: IndexSettingsInterface };
export type SelectOption<T extends string = string> = {
value: unknown;
text: T | ReactNode;
} & OptionHTMLAttributes<HTMLOptionElement>;
export interface ComboBoxOption {
label: string;
value?: unknown;
}
export interface SuperSelectOption {
value: unknown;
inputDisplay?: ReactNode;
dropdownDisplay?: ReactNode;
disabled?: boolean;
'data-test-subj'?: string;
}
export interface SearchResult {
display: JSX.Element;
field: NormalizedField;
}
export interface SearchMetadata {
/**
* Whether or not the search term match some part of the field path.
*/
matchPath: boolean;
/**
* If the search term matches the field type we will give it a higher score.
*/
matchType: boolean;
/**
* If the last word of the search terms matches the field name
*/
matchFieldName: boolean;
/**
* If the search term matches the beginning of the path we will give it a higher score
*/
matchStartOfPath: boolean;
/**
* If the last word of the search terms fully matches the field name
*/
fullyMatchFieldName: boolean;
/**
* If the search term exactly matches the field type
*/
fullyMatchType: boolean;
/**
* If the search term matches the full field path
*/
fullyMatchPath: boolean;
/**
* The score of the result that will allow us to sort the list
*/
score: number;
/**
* The JSX with <strong> tag wrapping the matched string
*/
display: JSX.Element;
/**
* The field path substring that matches the search
*/
stringMatch: string | null;
}
export interface GenericObject {
[key: string]: any;
}

View file

@ -0,0 +1,107 @@
/*
* 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 { FormHook, OnFormUpdateArg } from '../shared_imports';
import { Field, NormalizedFields } from './document_fields';
import { FieldsEditor, SearchResult } from './mappings_editor';
export type Mappings = MappingsTemplates &
MappingsConfiguration & {
properties?: MappingsFields;
};
export interface MappingsConfiguration {
enabled?: boolean;
throwErrorsForUnmappedFields?: boolean;
date_detection?: boolean;
numeric_detection?: boolean;
dynamic_date_formats?: string[];
_source?: {
enabled?: boolean;
includes?: string[];
excludes?: string[];
};
_meta?: string;
}
export interface MappingsTemplates {
dynamic_templates?: DynamicTemplate[];
}
export interface DynamicTemplate {
[key: string]: {
mapping: {
[key: string]: any;
};
match_mapping_type?: string;
match?: string;
unmatch?: string;
match_pattern?: string;
path_match?: string;
path_unmatch?: string;
};
}
export interface MappingsFields {
[key: string]: any;
}
export type DocumentFieldsStatus = 'idle' | 'editingField' | 'creatingField';
export interface DocumentFieldsState {
status: DocumentFieldsStatus;
editor: FieldsEditor;
fieldToEdit?: string;
fieldToAddFieldTo?: string;
}
export interface ConfigurationFormState extends OnFormUpdateArg<MappingsConfiguration> {
defaultValue: MappingsConfiguration;
submitForm?: FormHook<MappingsConfiguration>['submit'];
}
interface TemplatesFormState extends OnFormUpdateArg<MappingsTemplates> {
defaultValue: MappingsTemplates;
submitForm?: FormHook<MappingsTemplates>['submit'];
}
export interface State {
isValid: boolean | undefined;
configuration: ConfigurationFormState;
documentFields: DocumentFieldsState;
fields: NormalizedFields;
fieldForm?: OnFormUpdateArg<any>;
fieldsJsonEditor: {
format(): MappingsFields;
isValid: boolean;
};
search: {
term: string;
result: SearchResult[];
};
templates: TemplatesFormState;
}
export type Action =
| { type: 'editor.replaceMappings'; value: { [key: string]: any } }
| { type: 'configuration.update'; value: Partial<ConfigurationFormState> }
| { type: 'configuration.save'; value: MappingsConfiguration }
| { type: 'templates.update'; value: Partial<State['templates']> }
| { type: 'templates.save'; value: MappingsTemplates }
| { type: 'fieldForm.update'; value: OnFormUpdateArg<any> }
| { type: 'field.add'; value: Field }
| { type: 'field.remove'; value: string }
| { type: 'field.edit'; value: Field }
| { type: 'field.toggleExpand'; value: { fieldId: string; isExpanded?: boolean } }
| { type: 'documentField.createField'; value?: string }
| { type: 'documentField.editField'; value: string }
| { type: 'documentField.changeStatus'; value: DocumentFieldsStatus }
| { type: 'documentField.changeEditor'; value: FieldsEditor }
| { type: 'fieldsJsonEditor.update'; value: { json: { [key: string]: any }; isValid: boolean } }
| { type: 'search:update'; value: string }
| { type: 'validity:update'; value: boolean };
export type Dispatch = (action: Action) => void;

View file

@ -3,92 +3,32 @@
* 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, { useReducer, useEffect, createContext, useContext, useMemo, useRef } from 'react';
import { useEffect, useMemo } from 'react';
import {
reducer,
Field,
Mappings,
MappingsConfiguration,
MappingsFields,
MappingsTemplates,
State,
Dispatch,
} from './reducer';
import { Field } from './types';
OnUpdateHandler,
} from './types';
import { normalize, deNormalize, stripUndefinedValues } from './lib';
import { useMappingsState, useDispatch } from './mappings_state_context';
type Mappings = MappingsTemplates &
MappingsConfiguration & {
properties?: MappingsFields;
};
export interface Types {
Mappings: Mappings;
MappingsConfiguration: MappingsConfiguration;
MappingsFields: MappingsFields;
MappingsTemplates: MappingsTemplates;
}
export interface OnUpdateHandlerArg {
isValid?: boolean;
getData: () => Mappings | undefined;
validate: () => Promise<boolean>;
}
export type OnUpdateHandler = (arg: OnUpdateHandlerArg) => void;
const StateContext = createContext<State | undefined>(undefined);
const DispatchContext = createContext<Dispatch | undefined>(undefined);
export interface Props {
children: (params: { state: State }) => React.ReactNode;
value: {
interface Args {
onChange: OnUpdateHandler;
value?: {
templates: MappingsTemplates;
configuration: MappingsConfiguration;
fields: { [key: string]: Field };
};
onChange: OnUpdateHandler;
}
export const MappingsState = React.memo(({ children, onChange, value }: Props) => {
const didMountRef = useRef(false);
export const useMappingsStateListener = ({ onChange, value }: Args) => {
const state = useMappingsState();
const dispatch = useDispatch();
const parsedFieldsDefaultValue = useMemo(() => normalize(value.fields), [value.fields]);
const initialState: State = {
isValid: true,
configuration: {
defaultValue: value.configuration,
data: {
raw: value.configuration,
format: () => value.configuration,
},
validate: () => Promise.resolve(true),
},
templates: {
defaultValue: value.templates,
data: {
raw: value.templates,
format: () => value.templates,
},
validate: () => Promise.resolve(true),
},
fields: parsedFieldsDefaultValue,
documentFields: {
status: parsedFieldsDefaultValue.rootLevelFields.length === 0 ? 'creatingField' : 'idle',
editor: 'default',
},
fieldsJsonEditor: {
format: () => ({}),
isValid: true,
},
search: {
term: '',
result: [],
},
};
const [state, dispatch] = useReducer(reducer, initialState);
const parsedFieldsDefaultValue = useMemo(() => normalize(value?.fields), [value?.fields]);
useEffect(() => {
// If we are creating a new field, but haven't entered any name
@ -158,46 +98,28 @@ export const MappingsState = React.memo(({ children, onChange, value }: Props) =
},
isValid: state.isValid,
});
}, [state, onChange]);
}, [state, onChange, dispatch]);
useEffect(() => {
/**
* If the value has changed that probably means that we have loaded
* new data from JSON. We need to update our state with the new mappings.
*/
if (didMountRef.current) {
dispatch({
type: 'editor.replaceMappings',
value: {
configuration: value.configuration,
templates: value.templates,
fields: parsedFieldsDefaultValue,
},
});
} else {
didMountRef.current = true;
if (value === undefined) {
return;
}
}, [value, parsedFieldsDefaultValue]);
return (
<StateContext.Provider value={state}>
<DispatchContext.Provider value={dispatch}>{children({ state })}</DispatchContext.Provider>
</StateContext.Provider>
);
});
export const useMappingsState = () => {
const ctx = useContext(StateContext);
if (ctx === undefined) {
throw new Error('useMappingsState must be used within a <MappingsState>');
}
return ctx;
};
export const useDispatch = () => {
const ctx = useContext(DispatchContext);
if (ctx === undefined) {
throw new Error('useDispatch must be used within a <MappingsState>');
}
return ctx;
dispatch({
type: 'editor.replaceMappings',
value: {
configuration: value.configuration,
templates: value.templates,
fields: parsedFieldsDefaultValue,
documentFields: {
status: parsedFieldsDefaultValue.rootLevelFields.length === 0 ? 'creatingField' : 'idle',
editor: 'default',
},
},
});
}, [value, parsedFieldsDefaultValue, dispatch]);
};

View file

@ -39,7 +39,7 @@ const i18nTexts = {
),
};
export const StepComponents = ({ defaultValue = [], onChange, esDocsBase }: Props) => {
export const StepComponents = ({ defaultValue, onChange, esDocsBase }: Props) => {
const [state, setState] = useState<{
isLoadingComponents: boolean;
components: ComponentTemplateListItem[];

View file

@ -3,7 +3,7 @@
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React, { useEffect } from 'react';
import React, { useEffect, useCallback } from 'react';
import {
EuiFlexGroup,
EuiFlexItem,
@ -153,25 +153,18 @@ export const StepLogistics: React.FunctionComponent<Props> = React.memo(
serializer: formSerializer,
deserializer: formDeserializer,
});
const { subscribe, submit, isSubmitted, isValid: isFormValid, getErrors: getFormErrors } = form;
/**
* When the consumer call validate() on this step, we submit the form so it enters the "isSubmitted" state
* and we can display the form errors on top of the forms if there are any.
*/
const validate = async () => {
return (await form.submit()).isValid;
};
const validate = useCallback(async () => {
return (await submit()).isValid;
}, [submit]);
useEffect(() => {
onChange({
isValid: form.isValid,
validate,
getData: form.getFormData,
});
}, [form.isValid, onChange]); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
const subscription = form.subscribe(({ data, isValid }) => {
const subscription = subscribe(({ data, isValid }) => {
onChange({
isValid,
validate,
@ -179,7 +172,7 @@ export const StepLogistics: React.FunctionComponent<Props> = React.memo(
});
});
return subscription.unsubscribe;
}, [onChange]); // eslint-disable-line react-hooks/exhaustive-deps
}, [onChange, validate, subscribe]);
const { name, indexPatterns, dataStream, order, priority, version } = getFieldsMeta(
documentationService.getEsDocsBase()
@ -204,7 +197,7 @@ export const StepLogistics: React.FunctionComponent<Props> = React.memo(
<EuiButtonEmpty
size="s"
flush="right"
href={documentationService.getTemplatesDocumentationLink()}
href={documentationService.getTemplatesDocumentationLink(isLegacy)}
target="_blank"
iconType="help"
>
@ -220,8 +213,8 @@ export const StepLogistics: React.FunctionComponent<Props> = React.memo(
<Form
form={form}
isInvalid={form.isSubmitted && !form.isValid}
error={form.getErrors()}
isInvalid={isSubmitted && !isFormValid}
error={getFormErrors()}
data-test-subj="stepLogistics"
>
{/* Name */}

View file

@ -24,6 +24,7 @@ import { serializers } from '../../../../shared_imports';
import { serializeLegacyTemplate, serializeTemplate } from '../../../../../common/lib';
import { TemplateDeserialized, getTemplateParameter } from '../../../../../common';
import { SimulateTemplate } from '../../index_templates';
import { WizardSection } from '../template_form';
const { stripEmptyFields } = serializers;
@ -56,6 +57,27 @@ interface Props {
navigateToStep: (stepId: WizardSection) => void;
}
const PreviewTab = ({ template }: { template: { [key: string]: any } }) => {
return (
<div data-test-subj="previewTab">
<EuiSpacer size="m" />
<EuiText>
<p>
<FormattedMessage
id="xpack.idxMgmt.templateForm.stepReview.previewTab.descriptionText"
defaultMessage="This is the final template that will be applied to your indices."
/>
</p>
</EuiText>
<EuiSpacer size="m" />
<SimulateTemplate template={template} />
</div>
);
};
export const StepReview: React.FunctionComponent<Props> = React.memo(
({ template, navigateToStep }) => {
const {
@ -286,6 +308,33 @@ export const StepReview: React.FunctionComponent<Props> = React.memo(
);
};
const tabs = [
{
id: 'summary',
name: i18n.translate('xpack.idxMgmt.templateForm.stepReview.summaryTabTitle', {
defaultMessage: 'Summary',
}),
content: <SummaryTab />,
},
{
id: 'request',
name: i18n.translate('xpack.idxMgmt.templateForm.stepReview.requestTabTitle', {
defaultMessage: 'Request',
}),
content: <RequestTab />,
},
];
if (!isLegacy) {
tabs.splice(1, 0, {
id: 'preview',
name: i18n.translate('xpack.idxMgmt.templateForm.stepReview.previewTabTitle', {
defaultMessage: 'Preview',
}),
content: <PreviewTab template={template} />,
});
}
return (
<div data-test-subj="stepSummary">
<EuiTitle>
@ -331,25 +380,7 @@ export const StepReview: React.FunctionComponent<Props> = React.memo(
</Fragment>
) : null}
<EuiTabbedContent
data-test-subj="summaryTabContent"
tabs={[
{
id: 'summary',
name: i18n.translate('xpack.idxMgmt.templateForm.stepReview.summaryTabTitle', {
defaultMessage: 'Summary',
}),
content: <SummaryTab />,
},
{
id: 'request',
name: i18n.translate('xpack.idxMgmt.templateForm.stepReview.requestTabTitle', {
defaultMessage: 'Request',
}),
content: <RequestTab />,
},
]}
/>
<EuiTabbedContent data-test-subj="summaryTabContent" tabs={tabs} />
</div>
);
}

View file

@ -3,14 +3,19 @@
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React, { useCallback } from 'react';
import React, { useState, useCallback } from 'react';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n/react';
import { EuiSpacer } from '@elastic/eui';
import { EuiSpacer, EuiButton } from '@elastic/eui';
import { TemplateDeserialized } from '../../../../common';
import { serializers, Forms } from '../../../shared_imports';
import { serializers, Forms, GlobalFlyout } from '../../../shared_imports';
import { SectionError } from '../section_error';
import {
SimulateTemplateFlyoutContent,
SimulateTemplateProps,
simulateTemplateFlyoutProps,
} from '../index_templates';
import { StepLogisticsContainer, StepComponentContainer, StepReviewContainer } from './steps';
import {
CommonWizardSteps,
@ -22,8 +27,10 @@ import { documentationService } from '../../services/documentation';
const { stripEmptyFields } = serializers;
const { FormWizard, FormWizardStep } = Forms;
const { useGlobalFlyout } = GlobalFlyout;
interface Props {
title: string | JSX.Element;
onSave: (template: TemplateDeserialized) => void;
clearSaveError: () => void;
isSaving: boolean;
@ -80,6 +87,7 @@ const wizardSections: { [id: string]: { id: WizardSection; label: string } } = {
};
export const TemplateForm = ({
title,
defaultValue,
isEditing,
isSaving,
@ -88,6 +96,9 @@ export const TemplateForm = ({
clearSaveError,
onSave,
}: Props) => {
const [wizardContent, setWizardContent] = useState<Forms.Content<WizardContent> | null>(null);
const { addContent: addContentToGlobalFlyout, closeFlyout } = useGlobalFlyout();
const indexTemplate = defaultValue ?? {
name: '',
indexPatterns: [],
@ -189,6 +200,10 @@ export const TemplateForm = ({
[]
);
const onWizardContentChange = useCallback((content: Forms.Content<WizardContent>) => {
setWizardContent(content);
}, []);
const onSaveTemplate = useCallback(
async (wizardData: WizardContent) => {
const template = buildTemplateObject(indexTemplate)(wizardData);
@ -206,44 +221,101 @@ export const TemplateForm = ({
[indexTemplate, buildTemplateObject, onSave, clearSaveError]
);
const getSimulateTemplate = useCallback(async () => {
if (!wizardContent) {
return;
}
const isValid = await wizardContent.validate();
if (!isValid) {
return;
}
const wizardData = wizardContent.getData();
const template = buildTemplateObject(indexTemplate)(wizardData);
return template;
}, [buildTemplateObject, indexTemplate, wizardContent]);
const showPreviewFlyout = () => {
addContentToGlobalFlyout<SimulateTemplateProps>({
id: 'simulateTemplate',
Component: SimulateTemplateFlyoutContent,
props: {
getTemplate: getSimulateTemplate,
onClose: closeFlyout,
},
flyoutProps: simulateTemplateFlyoutProps,
});
};
const getRightContentWizardNav = (stepId: WizardSection) => {
if (isLegacy) {
return null;
}
// Don't show "Preview template" button on logistics and review steps
if (stepId === 'logistics' || stepId === 'review') {
return null;
}
return (
<EuiButton size="s" onClick={showPreviewFlyout}>
<FormattedMessage
id="xpack.idxMgmt.templateForm.previewIndexTemplateButtonLabel"
defaultMessage="Preview index template"
/>
</EuiButton>
);
};
return (
<FormWizard<WizardContent>
defaultValue={wizardDefaultValue}
onSave={onSaveTemplate}
isEditing={isEditing}
isSaving={isSaving}
apiError={apiError}
texts={i18nTexts}
>
<FormWizardStep
id={wizardSections.logistics.id}
label={wizardSections.logistics.label}
isRequired
<>
{/* Form header */}
{title}
<EuiSpacer size="l" />
<FormWizard<WizardContent, WizardSection>
defaultValue={wizardDefaultValue}
onSave={onSaveTemplate}
isEditing={isEditing}
isSaving={isSaving}
apiError={apiError}
texts={i18nTexts}
onChange={onWizardContentChange}
rightContentNav={getRightContentWizardNav}
>
<StepLogisticsContainer isEditing={isEditing} isLegacy={indexTemplate._kbnMeta.isLegacy} />
</FormWizardStep>
{indexTemplate._kbnMeta.isLegacy !== true && (
<FormWizardStep id={wizardSections.components.id} label={wizardSections.components.label}>
<StepComponentContainer />
<FormWizardStep
id={wizardSections.logistics.id}
label={wizardSections.logistics.label}
isRequired
>
<StepLogisticsContainer
isEditing={isEditing}
isLegacy={indexTemplate._kbnMeta.isLegacy}
/>
</FormWizardStep>
)}
<FormWizardStep id={wizardSections.settings.id} label={wizardSections.settings.label}>
<StepSettingsContainer esDocsBase={documentationService.getEsDocsBase()} />
</FormWizardStep>
{indexTemplate._kbnMeta.isLegacy !== true && (
<FormWizardStep id={wizardSections.components.id} label={wizardSections.components.label}>
<StepComponentContainer />
</FormWizardStep>
)}
<FormWizardStep id={wizardSections.mappings.id} label={wizardSections.mappings.label}>
<StepMappingsContainer esDocsBase={documentationService.getEsDocsBase()} />
</FormWizardStep>
<FormWizardStep id={wizardSections.settings.id} label={wizardSections.settings.label}>
<StepSettingsContainer esDocsBase={documentationService.getEsDocsBase()} />
</FormWizardStep>
<FormWizardStep id={wizardSections.aliases.id} label={wizardSections.aliases.label}>
<StepAliasesContainer esDocsBase={documentationService.getEsDocsBase()} />
</FormWizardStep>
<FormWizardStep id={wizardSections.mappings.id} label={wizardSections.mappings.label}>
<StepMappingsContainer esDocsBase={documentationService.getEsDocsBase()} />
</FormWizardStep>
<FormWizardStep id={wizardSections.review.id} label={wizardSections.review.label}>
<StepReviewContainer getTemplateData={buildTemplateObject(indexTemplate)} />
</FormWizardStep>
</FormWizard>
<FormWizardStep id={wizardSections.aliases.id} label={wizardSections.aliases.label}>
<StepAliasesContainer esDocsBase={documentationService.getEsDocsBase()} />
</FormWizardStep>
<FormWizardStep id={wizardSections.review.id} label={wizardSections.review.label}>
<StepReviewContainer getTemplateData={buildTemplateObject(indexTemplate)} />
</FormWizardStep>
</FormWizard>
</>
);
};

View file

@ -11,11 +11,14 @@ import { render, unmountComponentAtNode } from 'react-dom';
import { CoreStart } from '../../../../../src/core/public';
import { API_BASE_PATH } from '../../common';
import { GlobalFlyout } from '../shared_imports';
import { AppContextProvider, AppDependencies } from './app_context';
import { App } from './app';
import { indexManagementStore } from './store';
import { ComponentTemplatesProvider } from './components';
import { ComponentTemplatesProvider, MappingsEditorProvider } from './components';
const { GlobalFlyoutProvider } = GlobalFlyout;
export const renderApp = (
elem: HTMLElement | null,
@ -43,9 +46,13 @@ export const renderApp = (
<I18nContext>
<Provider store={indexManagementStore(services)}>
<AppContextProvider value={dependencies}>
<ComponentTemplatesProvider value={componentTemplateProviderValues}>
<App history={history} />
</ComponentTemplatesProvider>
<MappingsEditorProvider>
<ComponentTemplatesProvider value={componentTemplateProviderValues}>
<GlobalFlyoutProvider>
<App history={history} />
</GlobalFlyoutProvider>
</ComponentTemplatesProvider>
</MappingsEditorProvider>
</AppContextProvider>
</Provider>
</I18nContext>,

View file

@ -5,3 +5,4 @@
*/
export { TabSummary } from './tab_summary';
export { TabPreview } from './tab_preview';

View file

@ -0,0 +1,34 @@
/*
* 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 { EuiText, EuiSpacer } from '@elastic/eui';
import { TemplateDeserialized } from '../../../../../../../common';
import { SimulateTemplate } from '../../../../../components/index_templates';
interface Props {
templateDetails: TemplateDeserialized;
}
export const TabPreview = ({ templateDetails }: Props) => {
return (
<div data-test-subj="previewTabContent">
<EuiText>
<p>
<FormattedMessage
id="xpack.idxMgmt.templateDetails.previewTab.descriptionText"
defaultMessage="This is the final template that will be applied to your indices."
/>
</p>
</EuiText>
<EuiSpacer size="m" />
<SimulateTemplate template={templateDetails} />
</div>
);
};

View file

@ -15,8 +15,6 @@ export const TemplateDetails = (props: Props) => {
onClose={props.onClose}
data-test-subj="templateDetails"
aria-labelledby="templateDetailsFlyoutTitle"
size="m"
maxWidth={500}
>
<TemplateDetailsContent {...props} />
</EuiFlyout>

View file

@ -29,6 +29,7 @@ import {
UIM_TEMPLATE_DETAIL_PANEL_SUMMARY_TAB,
UIM_TEMPLATE_DETAIL_PANEL_SETTINGS_TAB,
UIM_TEMPLATE_DETAIL_PANEL_ALIASES_TAB,
UIM_TEMPLATE_DETAIL_PANEL_PREVIEW_TAB,
} from '../../../../../../common/constants';
import { SendRequestResponse } from '../../../../../shared_imports';
import { TemplateDeleteModal, SectionLoading, SectionError, Error } from '../../../../components';
@ -37,12 +38,13 @@ import { decodePathFromReactRouter } from '../../../../services/routing';
import { useServices } from '../../../../app_context';
import { TabAliases, TabMappings, TabSettings } from '../../../../components/shared';
import { TemplateTypeIndicator } from '../components';
import { TabSummary } from './tabs';
import { TabSummary, TabPreview } from './tabs';
const SUMMARY_TAB_ID = 'summary';
const MAPPINGS_TAB_ID = 'mappings';
const ALIASES_TAB_ID = 'aliases';
const SETTINGS_TAB_ID = 'settings';
const PREVIEW_TAB_ID = 'preview';
const TABS = [
{
@ -69,6 +71,12 @@ const TABS = [
defaultMessage: 'Aliases',
}),
},
{
id: PREVIEW_TAB_ID,
name: i18n.translate('xpack.idxMgmt.templateDetails.previewTabTitle', {
defaultMessage: 'Preview',
}),
},
];
const tabToUiMetricMap: { [key: string]: string } = {
@ -76,6 +84,7 @@ const tabToUiMetricMap: { [key: string]: string } = {
[SETTINGS_TAB_ID]: UIM_TEMPLATE_DETAIL_PANEL_SETTINGS_TAB,
[MAPPINGS_TAB_ID]: UIM_TEMPLATE_DETAIL_PANEL_MAPPINGS_TAB,
[ALIASES_TAB_ID]: UIM_TEMPLATE_DETAIL_PANEL_ALIASES_TAB,
[PREVIEW_TAB_ID]: UIM_TEMPLATE_DETAIL_PANEL_PREVIEW_TAB,
};
export interface Props {
@ -161,6 +170,7 @@ export const TemplateDetailsContent = ({
[SETTINGS_TAB_ID]: <TabSettings settings={settings} />,
[MAPPINGS_TAB_ID]: <TabMappings mappings={mappings} />,
[ALIASES_TAB_ID]: <TabAliases aliases={aliases} />,
[PREVIEW_TAB_ID]: <TabPreview templateDetails={templateDetails} />,
};
const tabContent = tabToComponentMap[activeTab];
@ -191,7 +201,13 @@ export const TemplateDetailsContent = ({
{managedTemplateCallout}
<EuiTabs>
{TABS.map((tab) => (
{TABS.filter((tab) => {
// Legacy index templates don't have the "simulate" template API
if (isLegacy && tab.id === PREVIEW_TAB_ID) {
return false;
}
return true;
}).map((tab) => (
<EuiTab
onClick={() => {
uiMetricService.trackMetric('click', tabToUiMetricMap[tab.id]);

View file

@ -6,7 +6,7 @@
import React, { useEffect, useState } from 'react';
import { RouteComponentProps } from 'react-router-dom';
import { FormattedMessage } from '@kbn/i18n/react';
import { EuiPageBody, EuiPageContent, EuiSpacer, EuiTitle } from '@elastic/eui';
import { EuiPageBody, EuiPageContent, EuiTitle } from '@elastic/eui';
import { TemplateDeserialized } from '../../../../common';
import { TemplateForm, SectionLoading, SectionError, Error } from '../../components';
@ -94,30 +94,30 @@ export const TemplateClone: React.FunctionComponent<RouteComponentProps<MatchPar
content = (
<TemplateForm
title={
<EuiTitle size="l">
<h1 data-test-subj="pageTitle">
<FormattedMessage
id="xpack.idxMgmt.createTemplate.cloneTemplatePageTitle"
defaultMessage="Clone template '{name}'"
values={{ name: decodedTemplateName }}
/>
</h1>
</EuiTitle>
}
defaultValue={templateData}
onSave={onSave}
isSaving={isSaving}
saveError={saveError}
clearSaveError={clearSaveError}
isLegacy={isLegacy}
/>
);
}
return (
<EuiPageBody>
<EuiPageContent>
<EuiTitle size="l">
<h1 data-test-subj="pageTitle">
<FormattedMessage
id="xpack.idxMgmt.createTemplate.cloneTemplatePageTitle"
defaultMessage="Clone template '{name}'"
values={{ name: decodedTemplateName }}
/>
</h1>
</EuiTitle>
<EuiSpacer size="l" />
{content}
</EuiPageContent>
<EuiPageContent>{content}</EuiPageContent>
</EuiPageBody>
);
};

View file

@ -6,7 +6,7 @@
import React, { useEffect, useState } from 'react';
import { RouteComponentProps } from 'react-router-dom';
import { FormattedMessage } from '@kbn/i18n/react';
import { EuiPageBody, EuiPageContent, EuiSpacer, EuiTitle } from '@elastic/eui';
import { EuiPageBody, EuiPageContent, EuiTitle } from '@elastic/eui';
import { useLocation } from 'react-router-dom';
import { parse } from 'query-string';
@ -51,23 +51,24 @@ export const TemplateCreate: React.FunctionComponent<RouteComponentProps> = ({ h
return (
<EuiPageBody>
<EuiPageContent>
<EuiTitle size="l">
<h1 data-test-subj="pageTitle">
{isLegacy ? (
<FormattedMessage
id="xpack.idxMgmt.createTemplate.createLegacyTemplatePageTitle"
defaultMessage="Create legacy template"
/>
) : (
<FormattedMessage
id="xpack.idxMgmt.createTemplate.createTemplatePageTitle"
defaultMessage="Create template"
/>
)}
</h1>
</EuiTitle>
<EuiSpacer size="l" />
<TemplateForm
title={
<EuiTitle size="l">
<h1 data-test-subj="pageTitle">
{isLegacy ? (
<FormattedMessage
id="xpack.idxMgmt.createTemplate.createLegacyTemplatePageTitle"
defaultMessage="Create legacy template"
/>
) : (
<FormattedMessage
id="xpack.idxMgmt.createTemplate.createTemplatePageTitle"
defaultMessage="Create template"
/>
)}
</h1>
</EuiTitle>
}
onSave={onSave}
isSaving={isSaving}
saveError={saveError}

View file

@ -133,12 +133,24 @@ export const TemplateEdit: React.FunctionComponent<RouteComponentProps<MatchPara
</Fragment>
)}
<TemplateForm
title={
<EuiTitle size="l">
<h1 data-test-subj="pageTitle">
<FormattedMessage
id="xpack.idxMgmt.editTemplate.editTemplatePageTitle"
defaultMessage="Edit template '{name}'"
values={{ name: decodedTemplateName }}
/>
</h1>
</EuiTitle>
}
defaultValue={template}
onSave={onSave}
isSaving={isSaving}
saveError={saveError}
clearSaveError={clearSaveError}
isEditing={true}
isLegacy={isLegacy}
/>
</Fragment>
);
@ -147,19 +159,7 @@ export const TemplateEdit: React.FunctionComponent<RouteComponentProps<MatchPara
return (
<EuiPageBody>
<EuiPageContent>
<EuiTitle size="l">
<h1 data-test-subj="pageTitle">
<FormattedMessage
id="xpack.idxMgmt.editTemplate.editTemplatePageTitle"
defaultMessage="Edit template '{name}'"
values={{ name: decodedTemplateName }}
/>
</h1>
</EuiTitle>
<EuiSpacer size="l" />
{content}
</EuiPageContent>
<EuiPageContent>{content}</EuiPageContent>
</EuiPageBody>
);
};

View file

@ -30,6 +30,7 @@ import {
UIM_TEMPLATE_CREATE,
UIM_TEMPLATE_UPDATE,
UIM_TEMPLATE_CLONE,
UIM_TEMPLATE_SIMULATE,
} from '../../../common/constants';
import { TemplateDeserialized, TemplateListItem, DataStream } from '../../../common';
import { IndexMgmtMetricsType } from '../../types';
@ -286,3 +287,14 @@ export async function updateTemplate(template: TemplateDeserialized) {
return result;
}
export function simulateIndexTemplate(template: { [key: string]: any }) {
return sendRequest({
path: `${API_BASE_PATH}/index_templates/simulate`,
method: 'post',
body: JSON.stringify(template),
}).then((result) => {
uiMetricService.trackMetric('count', UIM_TEMPLATE_SIMULATE);
return result;
});
}

View file

@ -40,8 +40,10 @@ class DocumentationService {
return `${this.esDocsBase}/data-streams.html`;
}
public getTemplatesDocumentationLink() {
return `${this.esDocsBase}/indices-templates.html`;
public getTemplatesDocumentationLink(isLegacy = false) {
return isLegacy
? `${this.esDocsBase}/indices-templates-v1.html`
: `${this.esDocsBase}/indices-templates.html`;
}
public getIdxMgmtDocumentationLink() {

View file

@ -22,6 +22,7 @@ export {
loadIndexMapping,
loadIndexData,
useLoadIndexTemplates,
simulateIndexTemplate,
} from './api';
export { healthToColor } from './health_to_color';
export { sortTable } from './sort_table';

View file

@ -12,6 +12,7 @@ export {
useRequest,
Forms,
extractQueryParams,
GlobalFlyout,
} from '../../../../src/plugins/es_ui_shared/public/';
export {

View file

@ -182,4 +182,14 @@ export const elasticsearchJsPlugin = (Client: any, config: any, components: any)
],
method: 'HEAD',
});
dataManagement.simulateTemplate = ca({
urls: [
{
fmt: '/_index_template/_simulate',
},
],
needBody: true,
method: 'POST',
});
};

View file

@ -0,0 +1,42 @@
/*
* 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 { schema, TypeOf } from '@kbn/config-schema';
import { RouteDependencies } from '../../../types';
import { addBasePath } from '../index';
const bodySchema = schema.object({}, { unknowns: 'allow' });
export function registerSimulateRoute({ router, license, lib }: RouteDependencies) {
router.post(
{
path: addBasePath('/index_templates/simulate'),
validate: { body: bodySchema },
},
license.guardApiRoute(async (ctx, req, res) => {
const { callAsCurrentUser } = ctx.dataManagement!.client;
const template = req.body as TypeOf<typeof bodySchema>;
try {
const templatePreview = await callAsCurrentUser('dataManagement.simulateTemplate', {
body: template,
});
return res.ok({ body: templatePreview });
} catch (e) {
if (lib.isEsError(e)) {
return res.customError({
statusCode: e.statusCode,
body: e,
});
}
// Case: default
return res.internalError({ body: e });
}
})
);
}

View file

@ -10,6 +10,7 @@ import { registerGetAllRoute, registerGetOneRoute } from './register_get_routes'
import { registerDeleteRoute } from './register_delete_route';
import { registerCreateRoute } from './register_create_route';
import { registerUpdateRoute } from './register_update_route';
import { registerSimulateRoute } from './register_simulate_route';
export function registerTemplateRoutes(dependencies: RouteDependencies) {
registerGetAllRoute(dependencies);
@ -17,4 +18,5 @@ export function registerTemplateRoutes(dependencies: RouteDependencies) {
registerDeleteRoute(dependencies);
registerCreateRoute(dependencies);
registerUpdateRoute(dependencies);
registerSimulateRoute(dependencies);
}