[Index template] Refactor index template wizard (#69037)

Co-authored-by: Alison Goryachev <alisonmllr20@gmail.com>
This commit is contained in:
Sébastien Loix 2020-06-18 18:28:12 +02:00 committed by GitHub
parent c58f4d54a1
commit 5e8d824a64
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
42 changed files with 1995 additions and 1044 deletions

View file

@ -0,0 +1,45 @@
# FormWizard
The `<FormWizard />` and `<FormWizardStep />` components lets us declare form wizard in a declarative way. It works hand in hand with the `MultiContent` explained above to make building form wizards a breeze. 😊
It takes care of enabling, disabling the `<EuiStepsHorizontal />` steps as well as the "Back" and "Next" button.
Let's see it through an example
```js
const MyForm = () => {
return (
<FormWizard<MyMultiContent>
defaultValue={wizardDefaultValue} // The MultiContent default value as explained above
onSave={onSaveTemplate} // A handler that will receive the multi-content data
isEditing={isEditing} // A boolean that will indicate if all steps are already "completed" and thus valid or if we need to complete them in order
isSaving={isSaving} // A boolean to show a "Saving..." text on the button on the last step
apiError={apiError} // Any API error to display on top of wizard
texts={i18nTexts} // i18n translations for the nav button.
>
<FormWizarStep id="contentOne" lable="Label for the step">
<div>
Here you can put anything... but you probably want to put a Container from the
MultiContent example above.
</div>
</FormWizarStep>
<FormWizarStep id="contentTwo" lable="Label for the step" isRequired>
<div>
Here you can put anything... but you probably want to put a Container from the
MultiContent example above.
</div>
</FormWizarStep>
<FormWizarStep id="contentThree" lable="Label for the step">
<div>
Here you can put anything... but you probably want to put a Container from the
MultiContent example above.
</div>
</FormWizarStep>
</FormWizard>
);
};
```
That's all we need to build a multi-step form wizard, making sure the data is cached when switching steps.

View file

@ -0,0 +1,139 @@
/*
* 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 from 'react';
import { EuiStepsHorizontal, EuiSpacer } from '@elastic/eui';
import {
FormWizardProvider,
FormWizardConsumer,
Props as ProviderProps,
} from './form_wizard_context';
import { FormWizardNav, NavTexts } from './form_wizard_nav';
interface Props<T extends object> extends ProviderProps<T> {
isSaving?: boolean;
apiError: JSX.Element | null;
texts?: Partial<NavTexts>;
}
export function FormWizard<T extends object = { [key: string]: any }>({
texts,
defaultActiveStep,
defaultValue,
apiError,
isEditing,
isSaving,
onSave,
onChange,
children,
}: Props<T>) {
return (
<FormWizardProvider<T>
defaultValue={defaultValue}
isEditing={isEditing}
onSave={onSave}
onChange={onChange}
defaultActiveStep={defaultActiveStep}
>
<FormWizardConsumer>
{({ activeStepIndex, lastStep, steps, isCurrentStepValid, navigateToStep }) => {
const stepsRequiredArray = Object.values(steps).map(
(step) => Boolean(step.isRequired) && step.isComplete === false
);
const getIsStepDisabled = (stepIndex: number) => {
// Disable all steps when the current step is invalid
if (stepIndex !== activeStepIndex && isCurrentStepValid === false) {
return true;
}
let isDisabled = false;
if (stepIndex > activeStepIndex + 1) {
/**
* Rule explained:
* - all the previous steps are always enabled (we can go back anytime)
* - the next step is also always enabled (it acts as the "Next" button)
* - for the rest, the step is disabled if any of the previous step (_greater_ than the current
* active step), is marked as isRequired **AND** has not been completed.
*/
isDisabled = stepsRequiredArray.reduce((acc, isRequired, i) => {
if (acc === true || i <= activeStepIndex || i >= stepIndex) {
return acc;
}
return Boolean(isRequired);
}, false);
}
return isDisabled;
};
const euiSteps = Object.values(steps).map(({ index, label }) => {
return {
title: label,
isComplete: activeStepIndex > index,
isSelected: activeStepIndex === index,
disabled: getIsStepDisabled(index),
onClick: () => navigateToStep(index),
};
});
const onBack = () => {
const prevStep = activeStepIndex - 1;
navigateToStep(prevStep);
};
const onNext = () => {
const nextStep = activeStepIndex + 1;
navigateToStep(nextStep);
};
return (
<>
{/* Horizontal Steps indicator */}
<EuiStepsHorizontal steps={euiSteps} />
<EuiSpacer size="l" />
{/* Any possible API error when saving/updating */}
{apiError}
{/* Active step content */}
{children}
<EuiSpacer size="l" />
{/* Button navigation */}
<FormWizardNav
activeStepIndex={activeStepIndex}
lastStep={lastStep}
isStepValid={isCurrentStepValid}
isSaving={isSaving}
onBack={onBack}
onNext={onNext}
texts={texts}
/>
</>
);
}}
</FormWizardConsumer>
</FormWizardProvider>
);
}

View file

@ -0,0 +1,173 @@
/*
* 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, { useState, createContext, useContext, useCallback } from 'react';
import { WithMultiContent, useMultiContentContext, HookProps } from '../multi_content';
export interface Props<T extends object> {
onSave: (data: T) => void | Promise<void>;
children: JSX.Element | JSX.Element[];
isEditing?: boolean;
defaultActiveStep?: number;
defaultValue?: HookProps<T>['defaultValue'];
onChange?: HookProps<T>['onChange'];
}
interface State {
activeStepIndex: number;
steps: Steps;
}
export interface Step {
id: string;
index: number;
label: string;
isRequired: boolean;
isComplete: boolean;
}
export interface Steps {
[stepId: string]: Step;
}
export interface Context<Id extends string = any> extends State {
activeStepId: Id;
lastStep: number;
isCurrentStepValid: boolean | undefined;
navigateToStep: (stepId: number | Id) => void;
addStep: (id: Id, label: string, isRequired?: boolean) => void;
}
const formWizardContext = createContext<Context>({} as Context);
export const FormWizardProvider = WithMultiContent<Props<any>>(function FormWizardProvider<
T extends object = { [key: string]: any }
>({ children, defaultActiveStep = 0, isEditing, onSave }: Props<T>) {
const { getData, validate, validation } = useMultiContentContext<T>();
const [state, setState] = useState<State>({
activeStepIndex: defaultActiveStep,
steps: {},
});
const activeStepId = state.steps[state.activeStepIndex]?.id;
const lastStep = Object.keys(state.steps).length - 1;
const isCurrentStepValid = validation.contents[activeStepId as keyof T];
const addStep = useCallback(
(id: string, label: string, isRequired = false) => {
setState((prev) => {
const index = Object.keys(prev.steps).length;
return {
...prev,
steps: {
...prev.steps,
[index]: { id, index, label, isRequired, isComplete: isEditing ?? false },
},
};
});
},
[isEditing]
);
/**
* Get the step index from a step id.
*/
const getStepIndex = useCallback(
(stepId: number | string) => {
if (typeof stepId === 'number') {
return stepId;
}
// We provided a string stepId, we need to find the corresponding index
const targetStep: Step | undefined = Object.values(state.steps).find(
(_step) => _step.id === stepId
);
if (!targetStep) {
throw new Error(`Can't navigate to step "${stepId}" as there are no step with that ID.`);
}
return targetStep.index;
},
[state.steps]
);
const navigateToStep = useCallback(
async (stepId: number | string) => {
// Before navigating away we validate the active content in the DOM
const isValid = await validate();
// If step is not valid do not go any further
if (!isValid) {
return;
}
const nextStepIndex = getStepIndex(stepId);
if (nextStepIndex > lastStep) {
// We are on the last step, save the data and don't go any further
onSave(getData() as T);
return;
}
// Update the active step
setState((prev) => {
const currentStep = prev.steps[prev.activeStepIndex];
const nextState = {
...prev,
activeStepIndex: nextStepIndex,
};
if (nextStepIndex > prev.activeStepIndex && !currentStep.isComplete) {
// Mark the current step as completed
nextState.steps[prev.activeStepIndex] = {
...currentStep,
isComplete: true,
};
}
return nextState;
});
},
[getStepIndex, validate, onSave, getData]
);
const value: Context = {
...state,
activeStepId,
lastStep,
isCurrentStepValid,
addStep,
navigateToStep,
};
return <formWizardContext.Provider value={value}>{children}</formWizardContext.Provider>;
});
export const FormWizardConsumer = formWizardContext.Consumer;
export function useFormWizardContext<T extends string = any>() {
const ctx = useContext(formWizardContext);
if (ctx === undefined) {
throw new Error('useFormWizardContext() must be called within a <FormWizardProvider />');
}
return ctx as Context<T>;
}

View file

@ -0,0 +1,105 @@
/*
* 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 from 'react';
import { i18n } from '@kbn/i18n';
import { EuiButton, EuiButtonEmpty, EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
interface Props {
activeStepIndex: number;
lastStep: number;
onBack: () => void;
onNext: () => void;
isSaving?: boolean;
isStepValid?: boolean;
texts?: Partial<NavTexts>;
}
export interface NavTexts {
back: string | JSX.Element;
next: string | JSX.Element;
save: string | JSX.Element;
saving: string | JSX.Element;
}
const DEFAULT_TEXTS = {
back: i18n.translate('esUi.formWizard.backButtonLabel', { defaultMessage: 'Back' }),
next: i18n.translate('esUi.formWizard.nextButtonLabel', { defaultMessage: 'Next' }),
save: i18n.translate('esUi.formWizard.saveButtonLabel', { defaultMessage: 'Save' }),
saving: i18n.translate('esUi.formWizard.savingButtonLabel', { defaultMessage: 'Saving...' }),
};
export const FormWizardNav = ({
activeStepIndex,
lastStep,
isStepValid,
isSaving,
onBack,
onNext,
texts,
}: Props) => {
const isLastStep = activeStepIndex === lastStep;
const labels = {
...DEFAULT_TEXTS,
...texts,
};
const nextButtonLabel = isLastStep
? Boolean(isSaving)
? labels.saving
: labels.save
: labels.next;
return (
<EuiFlexGroup justifyContent="spaceBetween">
<EuiFlexItem grow={false}>
<EuiFlexGroup>
{/* Back button */}
{activeStepIndex > 0 ? (
<EuiFlexItem grow={false}>
<EuiButtonEmpty
iconType="arrowLeft"
onClick={onBack}
data-test-subj="backButton"
disabled={isStepValid === false}
>
{labels.back}
</EuiButtonEmpty>
</EuiFlexItem>
) : null}
{/* Next button */}
<EuiFlexItem grow={false}>
<EuiButton
fill
iconType={isLastStep ? 'check' : 'arrowRight'}
onClick={onNext}
iconSide={isLastStep ? 'left' : 'right'}
disabled={isStepValid === false}
data-test-subj="nextButton"
isLoading={isSaving}
>
{nextButtonLabel}
</EuiButton>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
</EuiFlexGroup>
);
};

View file

@ -0,0 +1,39 @@
/*
* 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 { useEffect } from 'react';
import { useFormWizardContext } from './form_wizard_context';
interface Props {
id: string;
label: string;
children: JSX.Element;
isRequired?: boolean;
}
export const FormWizardStep = ({ id, label, isRequired, children }: Props) => {
const { activeStepId, addStep } = useFormWizardContext();
useEffect(() => {
addStep(id, label, isRequired);
}, [id, label, isRequired, addStep]);
return activeStepId === id ? children : null;
};

View file

@ -0,0 +1,32 @@
/*
* 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 { FormWizard } from './form_wizard';
export { FormWizardStep } from './form_wizard_step';
export {
FormWizardProvider,
FormWizardConsumer,
useFormWizardContext,
Step,
Steps,
} from './form_wizard_context';
export { FormWizardNav, NavTexts } from './form_wizard_nav';

View file

@ -0,0 +1,22 @@
/*
* 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 * from './form_wizard';
export * from './multi_content';

View file

@ -0,0 +1,157 @@
# MultiContent
## The problem
Building resource creations/edition flows in the UI, that have multiple contents that need to be merged together at the end of the flow and at the same time keeping a reference of each content state, is not trivial. Indeed, when we switch tab or we go to the next step, the old step data needs to be saved somewhere.
The first thing that comes to mind is: "Ok, I'll lift the state up" and make each step "content" a controlled component (when its value changes, it sends it to the global state and then it receives it back as prop). This works well up to a certain point. What happens if the internal state that the step content works with, is not the same as the outputted state?
Something like this:
```js
// StepOne internal state, flat map of fields
const internalState: {
fields: {
ate426jn: { name: 'hello', value: 'world', parent: 'rwtsdg3' },
rwtsdg3: { name: 'myObject', type: 'object' },
}
}
// Outputed data
const output = {
stepOne: {
myObject: {
hello: 'world'
}
}
}
```
We need some sort of serializer to go from the internal state to the output object. If we lift the state up this means that the global state needs to be aware of the intrinsic of the content, leaking implementation details.
This also means that the content **can't be a reusable component** as it depends on an external state to do part of its work (think: the mappings editor).
This is where `MultiContent` comes into play. It lets us declare `content` objects and automatically saves a snapshot of their content when the component unmounts (which occurs when switching a tab for example). If we navigate back to the tab, the tab content gets its `defaultValue` from that cache state.
Let see it through a concrete example
```js
// my_comp_wrapper.tsx
// Always good to have an interface for our contents
interface MyMultiContent {
contentOne: { myField: string };
contentTwo: { anotherField: string };
contentThree: { yetAnotherField: boolean };
}
// Each content data will be a slice of the multi-content defaultValue
const defaultValue: MyMultiContent = {
contentOne: {
myField: 'value',
},
contentTwo: {
anotherField: 'value',
},
contentThree: {
yetAnotherField: true,
},
};
```
```js
// my_comp.tsx
/**
* We wrap our component with the HOC that will provide the <MultiContentProvider /> and let us use the "useMultiContentContext()" hook
*
* MyComponent connects to the multi-content context and renders each step
* content without worrying about their internal state.
*/
const MyComponent = WithMultiContent(() => {
const { validation, getData, validate } = useMultiContentContext<MyMultiContent>();
const totalSteps = 3;
const [currentStep, setCurrentStep] = useState(0);
const renderContent = () => {
switch (currentStep) {
case 0:
return <ContentOneContainer />;
case 1:
return <ContentTwoContainer />;
case 2:
return <ContentThreeContainer />;
}
};
const onNext = () => {
// Validate the multi content
const isValid = await validate();
if (!isValid) {
return;
}
if (currentStep < totalSteps - 1) {
// Navigate to the next content
setCurrentStep((curentStep += 1));
} else {
// On last step we need to save so we read the multi-content data
console.log('About to save:', getData());
}
};
return (
<>
{renderContent()}
{/* Each content validity is accessible from the `validation.contents` object */}
<EuiButton onClick={onNext} disabled={validation.contents[currentStep] === false}>
Next
</EuiButton>
</>
);
});
```
```js
// content_one_container.tsx
// From the good old days of Redux, it is a good practice to separate the connection to the multi-content
// from the UI that is rendered.
const ContentOneContainer = () => {
// Declare a new content and get its default Value + a handler to update the content in the multi-content
// This will update the "contentOne" slice of the multi-content.
const { defaultValue, updateContent } = useContent<MyMultiContent>('contentOne');
return <ContentOne defaultValue={defaultValue} onChange={updateContent} />
};
```
```js
// content_one.tsx
const ContentOne = ({ defaultValue, onChange }) => {
// Use the defaultValue as a starting point for the internal state
const [internalStateValue, setInternalStateValue] = useState(defaultValue.myField);
useEffect(() => {
// Update the multi content state for this content
onChange({
isValid: true, // because in this example it is always valid
validate: async () => true,
getData: () => ({
myField: internalStateValue,
}),
});
}, [internalStateValue]);
return (
<input value={internalStateValue} onChange={(e) => setInternalStateValue(e.target.value)} />
);
}
```
And just like that, `<ContentOne />` is a reusable component that gets a `defaultValue` object and an `onChange` handler to communicate any internal state changes. He is responsible to provide a `getData()` handler as part of the `onChange` that will do any necessary serialization and sanitization, and the outside world does not need to know about it.

View file

@ -0,0 +1,29 @@
/*
* 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 {
MultiContentProvider,
MultiContentConsumer,
useMultiContentContext,
useContent,
} from './multi_content_context';
export { useMultiContent, HookProps, Content, MultiContent } from './use_multi_content';
export { WithMultiContent } from './with_multi_content';

View file

@ -0,0 +1,79 @@
/*
* 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, { useEffect, useCallback, createContext, useContext } from 'react';
import { useMultiContent, HookProps, Content, MultiContent } from './use_multi_content';
const multiContentContext = createContext<MultiContent<any>>({} as MultiContent<any>);
interface Props<T extends object> extends HookProps<T> {
children: JSX.Element | JSX.Element[];
}
export function MultiContentProvider<T extends object = { [key: string]: any }>({
defaultValue,
onChange,
children,
}: Props<T>) {
const multiContent = useMultiContent<T>({ defaultValue, onChange });
return (
<multiContentContext.Provider value={multiContent}>{children}</multiContentContext.Provider>
);
}
export const MultiContentConsumer = multiContentContext.Consumer;
export function useMultiContentContext<T extends object = { [key: string]: any }>() {
const ctx = useContext(multiContentContext);
if (Object.keys(ctx).length === 0) {
throw new Error('useMultiContentContext must be used within a <MultiContentProvider />');
}
return ctx as MultiContent<T>;
}
/**
* Hook to declare a new content and get its defaultValue and a handler to update its content
*
* @param contentId The content id to be added to the "contents" map
*/
export function useContent<T extends object = { [key: string]: any }>(contentId: keyof T) {
const { updateContentAt, saveSnapshotAndRemoveContent, getData } = useMultiContentContext<T>();
const updateContent = useCallback(
(content: Content) => {
updateContentAt(contentId, content);
},
[contentId, updateContentAt]
);
useEffect(() => {
return () => {
// On unmount: save a snapshot of the data and remove content from our contents map
saveSnapshotAndRemoveContent(contentId);
};
}, [contentId, saveSnapshotAndRemoveContent]);
return {
defaultValue: getData()[contentId]!,
updateContent,
getData,
};
}

View file

@ -0,0 +1,215 @@
/*
* 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 { useState, useCallback, useRef } from 'react';
export interface Content<T = any> {
isValid: boolean | undefined;
validate(): Promise<boolean>;
getData(): T;
}
type Contents<T> = {
[K in keyof T]: Content;
};
interface Validation<T extends object> {
isValid: boolean | undefined;
contents: {
[K in keyof T]: boolean | undefined;
};
}
export interface HookProps<T extends object> {
defaultValue?: T;
onChange?: (output: Content<T>) => void;
}
export interface MultiContent<T extends object> {
updateContentAt: (id: keyof T, content: Content) => void;
saveSnapshotAndRemoveContent: (id: keyof T) => void;
getData: () => T;
validate: () => Promise<boolean>;
validation: Validation<T>;
}
export function useMultiContent<T extends object>({
defaultValue,
onChange,
}: HookProps<T>): MultiContent<T> {
/**
* Each content validity is kept in this state. When updating a content with "updateContentAt()", we
* update the state validity and trigger a re-render.
*/
const [validation, setValidation] = useState<Validation<T>>({
isValid: true,
contents: {},
} as Validation<T>);
/**
* The updated data where a content current data is merged when it unmounts
*/
const [stateData, setStateData] = useState<T>(defaultValue ?? ({} as T));
/**
* A map object of all the active content(s) present in the DOM. In a multi step
* form wizard, there is only 1 content at the time in the DOM, but in long vertical
* flow content, multiple content could be present.
* When a content unmounts it will remove itself from this map.
*/
const contents = useRef<Contents<T>>({} as Contents<T>);
const updateContentDataAt = useCallback(function (updatedData: { [key in keyof T]?: any }) {
setStateData((prev) => ({
...prev,
...updatedData,
}));
}, []);
/**
* Read the multi content data.
*/
const getData = useCallback((): T => {
/**
* If there is one or more active content(s) in the DOM, and it is valid,
* we read its data and merge it into our stateData before returning it.
*/
const activeContentData: Partial<T> = {};
for (const [id, _content] of Object.entries(contents.current)) {
if (validation.contents[id as keyof T]) {
const contentData = (_content as Content).getData();
// Replace the getData() handler with the cached value
(_content as Content).getData = () => contentData;
activeContentData[id as keyof T] = contentData;
}
}
return {
...stateData,
...activeContentData,
};
}, [stateData, validation]);
const updateContentValidity = useCallback(
(updatedData: { [key in keyof T]?: boolean | undefined }): boolean | undefined => {
let allContentValidity: boolean | undefined;
setValidation((prev) => {
if (
Object.entries(updatedData).every(
([contentId, isValid]) => prev.contents[contentId as keyof T] === isValid
)
) {
// No change in validation, nothing to update
allContentValidity = prev.isValid;
return prev;
}
const nextContentsValidityState = {
...prev.contents,
...updatedData,
};
allContentValidity = Object.values(nextContentsValidityState).some(
(_isValid) => _isValid === undefined
)
? undefined
: Object.values(nextContentsValidityState).every(Boolean);
return {
isValid: allContentValidity,
contents: nextContentsValidityState,
};
});
return allContentValidity;
},
[]
);
/**
* Validate the multi-content active content(s) in the DOM
*/
const validate = useCallback(async () => {
const updatedValidation = {} as { [key in keyof T]?: boolean | undefined };
for (const [id, _content] of Object.entries(contents.current)) {
const isValid = await (_content as Content).validate();
(_content as Content).validate = async () => isValid;
updatedValidation[id as keyof T] = isValid;
}
return Boolean(updateContentValidity(updatedValidation));
}, [updateContentValidity]);
/**
* Update a content. It replaces the content in our "contents" map and update
* the state validation object.
*/
const updateContentAt = useCallback(
function (contentId: keyof T, content: Content) {
contents.current[contentId] = content;
const updatedValidity = { [contentId]: content.isValid } as {
[key in keyof T]: boolean | undefined;
};
const isValid = updateContentValidity(updatedValidity);
if (onChange !== undefined) {
onChange({
isValid,
validate,
getData,
});
}
},
[updateContentValidity, onChange]
);
/**
* When a content unmounts we want to save its current data state so we will be able
* to provide it as "defaultValue" the next time the component is mounted.
*/
const saveSnapshotAndRemoveContent = useCallback(
function (contentId: keyof T) {
if (contents.current[contentId]) {
// Merge the data in our stateData
const updatedData = {
[contentId]: contents.current[contentId].getData(),
} as { [key in keyof T]?: any };
updateContentDataAt(updatedData);
// Remove the content from our map
delete contents.current[contentId];
}
},
[updateContentDataAt]
);
return {
getData,
validate,
validation,
updateContentAt,
saveSnapshotAndRemoveContent,
};
}

View file

@ -0,0 +1,40 @@
/*
* 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 from 'react';
import { MultiContentProvider } from './multi_content_context';
import { HookProps } from './use_multi_content';
/**
* HOC to wrap a component with the MultiContentProvider
*
* @param Component The component to wrap with the MultiContentProvider
*/
export function WithMultiContent<
P extends object = { [key: string]: any } // The Props for the wrapped component
>(Component: React.FunctionComponent<P & HookProps<any>>) {
return function <T extends object = { [key: string]: any }>(props: P & HookProps<T>) {
const { defaultValue, onChange, ...rest } = props;
return (
<MultiContentProvider<T> defaultValue={defaultValue} onChange={onChange}>
<Component {...(rest as P)} />
</MultiContentProvider>
);
};
}

View file

@ -17,6 +17,12 @@
* under the License.
*/
/**
* Create a namespace for Forms
* In the future, each top level folder should be exported like that to avoid naming collision
*/
import * as Forms from './forms';
export { JsonEditor, OnJsonEditorUpdateHandler } from './components/json_editor';
export { SectionLoading } from './components/section_loading';
@ -63,6 +69,8 @@ export {
useAuthorizationContext,
} from './authorization';
export { Forms };
/** dummy plugin, we just want esUiShared to have its own bundle */
export function plugin() {
return new (class EsUiSharedPlugin {

View file

@ -113,7 +113,7 @@ export const ConfigurationForm = React.memo(({ value }: Props) => {
});
return subscription.unsubscribe;
}, [form, dispatch]);
}, [dispatch]); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
if (isMounted.current === undefined) {

View file

@ -56,7 +56,7 @@ export const CreateField = React.memo(function CreateFieldComponent({
});
return subscription.unsubscribe;
}, [form, dispatch]);
}, [dispatch]); // eslint-disable-line react-hooks/exhaustive-deps
const cancel = () => {
dispatch({ type: 'documentField.changeStatus', value: 'idle' });

View file

@ -32,7 +32,7 @@ export const EditFieldContainer = React.memo(({ field, allFields }: Props) => {
});
return subscription.unsubscribe;
}, [form, dispatch]);
}, [dispatch]); // eslint-disable-line react-hooks/exhaustive-deps
const exitEdit = useCallback(() => {
dispatch({ type: 'documentField.changeStatus', value: 'idle' });

View file

@ -4,7 +4,7 @@
* you may not use this file except in compliance with the Elastic License.
*/
import React, { useEffect, useMemo, useCallback } from 'react';
import React, { useMemo, useCallback } from 'react';
import { EuiButtonEmpty, EuiSpacer } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
@ -25,15 +25,6 @@ export const DocumentFieldsTreeEditor = () => {
dispatch({ type: 'documentField.createField' });
}, [dispatch]);
useEffect(() => {
/**
* If there aren't any fields yet, we display the create field form
*/
if (status === 'idle' && fields.length === 0) {
addField();
}
}, [addField, fields, status]);
const renderCreateField = () => {
// The "fieldToAddFieldTo" is undefined when adding to the top level "properties" object.
const isCreateFieldFormVisible = status === 'creatingField' && fieldToAddFieldTo === undefined;

View file

@ -69,7 +69,7 @@ export const TemplatesForm = React.memo(({ value }: Props) => {
});
});
return subscription.unsubscribe;
}, [form, dispatch]);
}, [dispatch]); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
if (isMounted.current === undefined) {

View file

@ -11,3 +11,5 @@ export * from './mappings_editor';
export * from './components/load_mappings';
export { OnUpdateHandler, Types } from './mappings_state';
export { IndexSettings } from './types';

View file

@ -13,7 +13,7 @@ interface Props {
children: React.ReactNode;
}
export const IndexSettingsProvider = ({ indexSettings, children }: Props) => (
export const IndexSettingsProvider = ({ indexSettings = {}, children }: Props) => (
<IndexSettingsContext.Provider value={indexSettings}>{children}</IndexSettingsContext.Provider>
);

View file

@ -6,63 +6,9 @@
jest.mock('../constants', () => ({ MAIN_DATA_TYPE_DEFINITION: {} }));
import { isStateValid, stripUndefinedValues } from './utils';
import { stripUndefinedValues } from './utils';
describe('utils', () => {
describe('isStateValid()', () => {
let components: any;
it('handles base case', () => {
components = {
fieldsJsonEditor: { isValid: undefined },
configuration: { isValid: undefined },
fieldForm: undefined,
};
expect(isStateValid(components)).toBe(undefined);
});
it('handles combinations of true, false and undefined', () => {
components = {
fieldsJsonEditor: { isValid: false },
configuration: { isValid: true },
fieldForm: undefined,
};
expect(isStateValid(components)).toBe(false);
components = {
fieldsJsonEditor: { isValid: false },
configuration: { isValid: undefined },
fieldForm: undefined,
};
expect(isStateValid(components)).toBe(undefined);
components = {
fieldsJsonEditor: { isValid: true },
configuration: { isValid: undefined },
fieldForm: undefined,
};
expect(isStateValid(components)).toBe(undefined);
components = {
fieldsJsonEditor: { isValid: true },
configuration: { isValid: false },
fieldForm: undefined,
};
expect(isStateValid(components)).toBe(false);
components = {
fieldsJsonEditor: { isValid: false },
configuration: { isValid: true },
fieldForm: { isValid: true },
};
expect(isStateValid(components)).toBe(false);
});
});
describe('stripUndefinedValues()', () => {
test('should remove all undefined value recursively', () => {
const myDate = new Date();

View file

@ -30,7 +30,6 @@ import {
MAIN_DATA_TYPE_DEFINITION,
} from '../constants';
import { State } from '../reducer';
import { FieldConfig } from '../shared_imports';
import { TreeItem } from '../components/tree';
@ -517,24 +516,6 @@ export const shouldDeleteChildFieldsAfterTypeChange = (
export const canUseMappingsEditor = (maxNestedDepth: number) =>
maxNestedDepth < MAX_DEPTH_DEFAULT_EDITOR;
const stateWithValidity: Array<keyof State> = ['configuration', 'fieldsJsonEditor', 'fieldForm'];
export const isStateValid = (state: State): boolean | undefined =>
Object.entries(state)
.filter(([key]) => stateWithValidity.includes(key as keyof State))
.reduce((isValid, { 1: value }) => {
if (value === undefined) {
return isValid;
}
// If one section validity of the state is "undefined", the mappings validity is also "undefined"
if (isValid === undefined || value.isValid === undefined) {
return undefined;
}
return isValid && value.isValid;
}, true as undefined | boolean);
/**
* This helper removes all the keys on an object with an "undefined" value.
* To avoid sending updates from the mappings editor with this type of object:

View file

@ -8,7 +8,6 @@ import React, { useReducer, useEffect, createContext, useContext, useMemo, useRe
import {
reducer,
addFieldToState,
MappingsConfiguration,
MappingsFields,
MappingsTemplates,
@ -32,7 +31,7 @@ export interface Types {
export interface OnUpdateHandlerArg {
isValid?: boolean;
getData: (isValid: boolean) => Mappings;
getData: () => Mappings;
validate: () => Promise<boolean>;
}
@ -57,7 +56,7 @@ export const MappingsState = React.memo(({ children, onChange, value }: Props) =
const parsedFieldsDefaultValue = useMemo(() => normalize(value.fields), [value.fields]);
const initialState: State = {
isValid: undefined,
isValid: true,
configuration: {
defaultValue: value.configuration,
data: {
@ -76,7 +75,7 @@ export const MappingsState = React.memo(({ children, onChange, value }: Props) =
},
fields: parsedFieldsDefaultValue,
documentFields: {
status: 'idle',
status: parsedFieldsDefaultValue.rootLevelFields.length === 0 ? 'creatingField' : 'idle',
editor: 'default',
},
fieldsJsonEditor: {
@ -105,32 +104,15 @@ export const MappingsState = React.memo(({ children, onChange, value }: Props) =
onChange({
// Output a mappings object from the user's input.
getData: (isValid: boolean) => {
let nextState = state;
if (
state.fieldForm &&
state.documentFields.status === 'creatingField' &&
isValid &&
!bypassFieldFormValidation
) {
// If the form field is valid and we are creating a new field that has some data
// we automatically add the field to our state.
const fieldFormData = state.fieldForm.data.format() as Field;
if (Object.keys(fieldFormData).length !== 0) {
nextState = addFieldToState(fieldFormData, state);
dispatch({ type: 'field.add', value: fieldFormData });
}
}
getData: () => {
// Pull the mappings properties from the current editor
const fields =
nextState.documentFields.editor === 'json'
? nextState.fieldsJsonEditor.format()
: deNormalize(nextState.fields);
state.documentFields.editor === 'json'
? state.fieldsJsonEditor.format()
: deNormalize(state.fields);
const configurationData = nextState.configuration.data.format();
const templatesData = nextState.templates.data.format();
const configurationData = state.configuration.data.format();
const templatesData = state.templates.data.format();
return {
...stripUndefinedValues({
@ -163,9 +145,11 @@ export const MappingsState = React.memo(({ children, onChange, value }: Props) =
promisesToValidate.push(state.fieldForm.validate());
}
return Promise.all(promisesToValidate).then(
(validationArray) => validationArray.every(Boolean) && state.fieldsJsonEditor.isValid
);
return Promise.all(promisesToValidate).then((validationArray) => {
const isValid = validationArray.every(Boolean) && state.fieldsJsonEditor.isValid;
dispatch({ type: 'validity:update', value: isValid });
return isValid;
});
},
isValid: state.isValid,
});

View file

@ -11,7 +11,6 @@ import {
shouldDeleteChildFieldsAfterTypeChange,
getAllChildFields,
getMaxNestedDepth,
isStateValid,
normalize,
updateFieldsPathAfterFieldNameChange,
searchFields,
@ -106,7 +105,8 @@ export type Action =
| { 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: 'search:update'; value: string }
| { type: 'validity:update'; value: boolean };
export type Dispatch = (action: Action) => void;
@ -164,7 +164,7 @@ export const addFieldToState = (field: Field, state: State): State => {
return {
...state,
isValid: isStateValid(state),
isValid: true,
fields: updatedFields,
};
};
@ -293,8 +293,7 @@ export const reducer = (state: State, action: Action): State => {
configuration: { ...state.configuration, ...action.value },
};
const isValid = isStateValid(nextState);
nextState.isValid = isValid;
nextState.isValid = action.value.isValid;
return nextState;
}
case 'configuration.save': {
@ -317,8 +316,7 @@ export const reducer = (state: State, action: Action): State => {
templates: { ...state.templates, ...action.value },
};
const isValid = isStateValid(nextState);
nextState.isValid = isValid;
nextState.isValid = action.value.isValid;
return nextState;
}
@ -342,8 +340,7 @@ export const reducer = (state: State, action: Action): State => {
fieldForm: action.value,
};
const isValid = isStateValid(nextState);
nextState.isValid = isValid;
nextState.isValid = action.value.isValid;
return nextState;
}
@ -529,7 +526,7 @@ export const reducer = (state: State, action: Action): State => {
return {
...state,
isValid: isStateValid(state),
isValid: true,
fieldForm: undefined,
fields: updatedFields,
documentFields: {
@ -577,7 +574,7 @@ export const reducer = (state: State, action: Action): State => {
},
};
nextState.isValid = isStateValid(nextState);
nextState.isValid = action.value.isValid;
return nextState;
}
@ -590,6 +587,12 @@ export const reducer = (state: State, action: Action): State => {
},
};
}
case 'validity:update': {
return {
...state,
isValid: action.value,
};
}
default:
throw new Error(`Action "${action!.type}" not recognized.`);
}

View file

@ -4,8 +4,8 @@
* you may not use this file except in compliance with the Elastic License.
*/
export { StepLogistics } from './step_logistics';
export { StepAliases } from './step_aliases';
export { StepMappings } from './step_mappings';
export { StepSettings } from './step_settings';
export { StepReview } from './step_review';
export { StepLogisticsContainer } from './step_logistics_container';
export { StepAliasesContainer } from './step_aliases_container';
export { StepMappingsContainer } from './step_mappings_container';
export { StepSettingsContainer } from './step_settings_container';
export { StepReviewContainer } from './step_review_container';

View file

@ -18,119 +18,119 @@ import {
EuiCode,
} from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n/react';
import { Forms } from '../../../../shared_imports';
import { documentationService } from '../../../services/documentation';
import { StepProps } from '../types';
import { useJsonStep } from './use_json_step';
export const StepAliases: React.FunctionComponent<StepProps> = ({
template,
setDataGetter,
onStepValidityChange,
}) => {
const { content, setContent, error } = useJsonStep({
prop: 'aliases',
defaultValue: template?.template.aliases,
setDataGetter,
onStepValidityChange,
});
interface Props {
defaultValue: { [key: string]: any };
onChange: (content: Forms.Content) => void;
}
return (
<div data-test-subj="stepAliases">
<EuiFlexGroup justifyContent="spaceBetween">
<EuiFlexItem grow={false}>
<EuiTitle>
<h2 data-test-subj="stepTitle">
export const StepAliases: React.FunctionComponent<Props> = React.memo(
({ defaultValue, onChange }) => {
const { jsonContent, setJsonContent, error } = useJsonStep({
defaultValue,
onChange,
});
return (
<div data-test-subj="stepAliases">
<EuiFlexGroup justifyContent="spaceBetween">
<EuiFlexItem grow={false}>
<EuiTitle>
<h2 data-test-subj="stepTitle">
<FormattedMessage
id="xpack.idxMgmt.templateForm.stepAliases.stepTitle"
defaultMessage="Aliases (optional)"
/>
</h2>
</EuiTitle>
<EuiSpacer size="s" />
<EuiText>
<p>
<FormattedMessage
id="xpack.idxMgmt.templateForm.stepAliases.aliasesDescription"
defaultMessage="Set up aliases to associate with your indices."
/>
</p>
</EuiText>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButtonEmpty
size="s"
flush="right"
href={documentationService.getTemplatesDocumentationLink()}
target="_blank"
iconType="help"
>
<FormattedMessage
id="xpack.idxMgmt.templateForm.stepAliases.stepTitle"
defaultMessage="Aliases (optional)"
id="xpack.idxMgmt.templateForm.stepAliases.docsButtonLabel"
defaultMessage="Index Templates docs"
/>
</h2>
</EuiTitle>
</EuiButtonEmpty>
</EuiFlexItem>
</EuiFlexGroup>
<EuiSpacer size="s" />
<EuiSpacer size="l" />
<EuiText>
<p>
<FormattedMessage
id="xpack.idxMgmt.templateForm.stepAliases.aliasesDescription"
defaultMessage="Set up aliases to associate with your indices."
/>
</p>
</EuiText>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButtonEmpty
size="s"
flush="right"
href={documentationService.getTemplatesDocumentationLink()}
target="_blank"
iconType="help"
>
{/* Aliases code editor */}
<EuiFormRow
label={
<FormattedMessage
id="xpack.idxMgmt.templateForm.stepAliases.docsButtonLabel"
defaultMessage="Index Templates docs"
id="xpack.idxMgmt.templateForm.stepAliases.fieldAliasesLabel"
defaultMessage="Aliases"
/>
</EuiButtonEmpty>
</EuiFlexItem>
</EuiFlexGroup>
<EuiSpacer size="l" />
{/* Aliases code editor */}
<EuiFormRow
label={
<FormattedMessage
id="xpack.idxMgmt.templateForm.stepAliases.fieldAliasesLabel"
defaultMessage="Aliases"
/>
}
helpText={
<FormattedMessage
id="xpack.idxMgmt.templateForm.stepAliases.aliasesEditorHelpText"
defaultMessage="Use JSON format: {code}"
values={{
code: (
<EuiCode>
{JSON.stringify({
my_alias: {},
})}
</EuiCode>
),
}
helpText={
<FormattedMessage
id="xpack.idxMgmt.templateForm.stepAliases.aliasesEditorHelpText"
defaultMessage="Use JSON format: {code}"
values={{
code: (
<EuiCode>
{JSON.stringify({
my_alias: {},
})}
</EuiCode>
),
}}
/>
}
isInvalid={Boolean(error)}
error={error}
fullWidth
>
<EuiCodeEditor
mode="json"
theme="textmate"
width="100%"
height="500px"
setOptions={{
showLineNumbers: false,
tabSize: 2,
}}
editorProps={{
$blockScrolling: Infinity,
}}
showGutter={false}
minLines={6}
aria-label={i18n.translate(
'xpack.idxMgmt.templateForm.stepAliases.fieldAliasesAriaLabel',
{
defaultMessage: 'Aliases code editor',
}
)}
value={jsonContent}
onChange={setJsonContent}
data-test-subj="aliasesEditor"
/>
}
isInvalid={Boolean(error)}
error={error}
fullWidth
>
<EuiCodeEditor
mode="json"
theme="textmate"
width="100%"
height="500px"
setOptions={{
showLineNumbers: false,
tabSize: 2,
}}
editorProps={{
$blockScrolling: Infinity,
}}
showGutter={false}
minLines={6}
aria-label={i18n.translate(
'xpack.idxMgmt.templateForm.stepAliases.fieldAliasesAriaLabel',
{
defaultMessage: 'Aliases code editor',
}
)}
value={content}
onChange={(updated: string) => {
setContent(updated);
}}
data-test-subj="aliasesEditor"
/>
</EuiFormRow>
</div>
);
};
</EuiFormRow>
</div>
);
}
);

View file

@ -0,0 +1,16 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React from 'react';
import { Forms } from '../../../../shared_imports';
import { WizardContent } from '../template_form';
import { StepAliases } from './step_aliases';
export const StepAliasesContainer = () => {
const { defaultValue, updateContent } = Forms.useContent<WizardContent>('aliases');
return <StepAliases defaultValue={defaultValue} onChange={updateContent} />;
};

View file

@ -8,9 +8,8 @@ import { EuiFlexGroup, EuiFlexItem, EuiTitle, EuiButtonEmpty, EuiSpacer } from '
import { FormattedMessage } from '@kbn/i18n/react';
import { i18n } from '@kbn/i18n';
import { useForm, Form, getUseField, getFormRow, Field } from '../../../../shared_imports';
import { useForm, Form, getUseField, getFormRow, Field, Forms } from '../../../../shared_imports';
import { documentationService } from '../../../services/documentation';
import { StepProps } from '../types';
import { schemas, nameConfig, nameConfigWithoutValidations } from '../template_form_schemas';
// Create or Form components with partial props that are common to all instances
@ -59,96 +58,102 @@ const fieldsMeta = {
},
};
export const StepLogistics: React.FunctionComponent<StepProps> = ({
template,
isEditing,
setDataGetter,
onStepValidityChange,
}) => {
const { form } = useForm({
schema: schemas.logistics,
defaultValue: template,
options: { stripEmptyFields: false },
});
interface Props {
defaultValue: { [key: string]: any };
onChange: (content: Forms.Content) => void;
isEditing?: boolean;
}
useEffect(() => {
onStepValidityChange(form.isValid);
}, [form.isValid, onStepValidityChange]);
export const StepLogistics: React.FunctionComponent<Props> = React.memo(
({ defaultValue, isEditing, onChange }) => {
const { form } = useForm({
schema: schemas.logistics,
defaultValue,
options: { stripEmptyFields: false },
});
useEffect(() => {
setDataGetter(form.submit);
}, [form.submit, setDataGetter]);
useEffect(() => {
const validate = async () => {
return (await form.submit()).isValid;
};
onChange({
isValid: form.isValid,
validate,
getData: form.getFormData,
});
}, [form.isValid, onChange]); // eslint-disable-line react-hooks/exhaustive-deps
const { name, indexPatterns, order, version } = fieldsMeta;
const { name, indexPatterns, order, version } = fieldsMeta;
return (
<Form form={form} data-test-subj="stepLogistics">
<EuiFlexGroup justifyContent="spaceBetween">
<EuiFlexItem grow={false}>
<EuiTitle>
<h2>
return (
<Form form={form} data-test-subj="stepLogistics">
<EuiFlexGroup justifyContent="spaceBetween">
<EuiFlexItem grow={false}>
<EuiTitle>
<h2>
<FormattedMessage
id="xpack.idxMgmt.templateForm.stepLogistics.stepTitle"
defaultMessage="Logistics"
/>
</h2>
</EuiTitle>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButtonEmpty
size="s"
flush="right"
href={documentationService.getTemplatesDocumentationLink()}
target="_blank"
iconType="help"
>
<FormattedMessage
id="xpack.idxMgmt.templateForm.stepLogistics.stepTitle"
defaultMessage="Logistics"
id="xpack.idxMgmt.templateForm.stepLogistics.docsButtonLabel"
defaultMessage="Index Templates docs"
/>
</h2>
</EuiTitle>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButtonEmpty
size="s"
flush="right"
href={documentationService.getTemplatesDocumentationLink()}
target="_blank"
iconType="help"
>
<FormattedMessage
id="xpack.idxMgmt.templateForm.stepLogistics.docsButtonLabel"
defaultMessage="Index Templates docs"
/>
</EuiButtonEmpty>
</EuiFlexItem>
</EuiFlexGroup>
<EuiSpacer size="l" />
{/* Name */}
<FormRow title={name.title} description={name.description}>
<UseField
path="name"
componentProps={{
['data-test-subj']: name.testSubject,
euiFieldProps: { disabled: isEditing },
}}
config={isEditing ? nameConfigWithoutValidations : nameConfig}
/>
</FormRow>
{/* Index patterns */}
<FormRow title={indexPatterns.title} description={indexPatterns.description}>
<UseField
path="indexPatterns"
componentProps={{
['data-test-subj']: indexPatterns.testSubject,
}}
/>
</FormRow>
{/* Order */}
<FormRow title={order.title} description={order.description}>
<UseField
path="order"
componentProps={{
['data-test-subj']: order.testSubject,
}}
/>
</FormRow>
{/* Version */}
<FormRow title={version.title} description={version.description}>
<UseField
path="version"
componentProps={{
['data-test-subj']: version.testSubject,
}}
/>
</FormRow>
</Form>
);
};
</EuiButtonEmpty>
</EuiFlexItem>
</EuiFlexGroup>
<EuiSpacer size="l" />
{/* Name */}
<FormRow title={name.title} description={name.description}>
<UseField
path="name"
componentProps={{
['data-test-subj']: name.testSubject,
euiFieldProps: { disabled: isEditing },
}}
config={isEditing ? nameConfigWithoutValidations : nameConfig}
/>
</FormRow>
{/* Index patterns */}
<FormRow title={indexPatterns.title} description={indexPatterns.description}>
<UseField
path="indexPatterns"
componentProps={{
['data-test-subj']: indexPatterns.testSubject,
}}
/>
</FormRow>
{/* Order */}
<FormRow title={order.title} description={order.description}>
<UseField
path="order"
componentProps={{
['data-test-subj']: order.testSubject,
}}
/>
</FormRow>
{/* Version */}
<FormRow title={version.title} description={version.description}>
<UseField
path="version"
componentProps={{
['data-test-subj']: version.testSubject,
}}
/>
</FormRow>
</Form>
);
}
);

View file

@ -0,0 +1,22 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React from 'react';
import { Forms } from '../../../../shared_imports';
import { WizardContent } from '../template_form';
import { StepLogistics } from './step_logistics';
interface Props {
isEditing?: boolean;
}
export const StepLogisticsContainer = ({ isEditing = false }: Props) => {
const { defaultValue, updateContent } = Forms.useContent<WizardContent>('logistics');
return (
<StepLogistics defaultValue={defaultValue} onChange={updateContent} isEditing={isEditing} />
);
};

View file

@ -14,99 +14,103 @@ import {
EuiSpacer,
EuiText,
} from '@elastic/eui';
import { Forms } from '../../../../shared_imports';
import { documentationService } from '../../../services/documentation';
import { StepProps, DataGetterFunc } from '../types';
import { MappingsEditor, OnUpdateHandler, LoadMappingsFromJsonButton } from '../../mappings_editor';
import {
MappingsEditor,
OnUpdateHandler,
LoadMappingsFromJsonButton,
IndexSettings,
} from '../../mappings_editor';
export const StepMappings: React.FunctionComponent<StepProps> = ({
template,
setDataGetter,
onStepValidityChange,
}) => {
const [mappings, setMappings] = useState(template?.template.mappings);
interface Props {
defaultValue: { [key: string]: any };
onChange: (content: Forms.Content) => void;
indexSettings?: IndexSettings;
}
const onMappingsEditorUpdate = useCallback<OnUpdateHandler>(
({ isValid, getData, validate }) => {
onStepValidityChange(isValid);
export const StepMappings: React.FunctionComponent<Props> = React.memo(
({ defaultValue, onChange, indexSettings }) => {
const [mappings, setMappings] = useState(defaultValue);
const dataGetterFunc: DataGetterFunc = async () => {
const isMappingsValid = isValid === undefined ? await validate() : isValid;
const data = getData(isMappingsValid);
return {
isValid: isMappingsValid,
data: { mappings: data },
path: 'template',
};
};
const onMappingsEditorUpdate = useCallback<OnUpdateHandler>(
({ isValid, getData, validate }) => {
onChange({
isValid,
async validate() {
return isValid === undefined ? await validate() : isValid;
},
getData,
});
},
[onChange]
);
setDataGetter(dataGetterFunc);
},
[setDataGetter, onStepValidityChange]
);
const onJsonLoaded = (json: { [key: string]: any }): void => {
setMappings(json);
};
const onJsonLoaded = (json: { [key: string]: any }): void => {
setMappings(json);
};
return (
<div data-test-subj="stepMappings">
<EuiFlexGroup justifyContent="spaceBetween">
<EuiFlexItem grow={false}>
<EuiTitle>
<h2 data-test-subj="stepTitle">
<FormattedMessage
id="xpack.idxMgmt.templateForm.stepMappings.stepTitle"
defaultMessage="Mappings (optional)"
/>
</h2>
</EuiTitle>
<EuiSpacer size="s" />
<EuiText>
<p>
<FormattedMessage
id="xpack.idxMgmt.templateForm.stepMappings.mappingsDescription"
defaultMessage="Define how to store and index documents."
/>
</p>
</EuiText>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiFlexGroup gutterSize="s">
<EuiFlexItem grow={false}>
<LoadMappingsFromJsonButton onJson={onJsonLoaded} />
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButtonEmpty
size="s"
flush="right"
href={documentationService.getMappingDocumentationLink()}
target="_blank"
iconType="help"
>
return (
<div data-test-subj="stepMappings">
<EuiFlexGroup justifyContent="spaceBetween">
<EuiFlexItem grow={false}>
<EuiTitle>
<h2 data-test-subj="stepTitle">
<FormattedMessage
id="xpack.idxMgmt.templateForm.stepMappings.docsButtonLabel"
defaultMessage="Mapping docs"
id="xpack.idxMgmt.templateForm.stepMappings.stepTitle"
defaultMessage="Mappings (optional)"
/>
</EuiButtonEmpty>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
</EuiFlexGroup>
</h2>
</EuiTitle>
<EuiSpacer size="s" />
<EuiSpacer size="s" />
{/* Mappings code editor */}
<MappingsEditor
value={mappings}
onChange={onMappingsEditorUpdate}
indexSettings={template?.template.settings}
/>
<EuiText>
<p>
<FormattedMessage
id="xpack.idxMgmt.templateForm.stepMappings.mappingsDescription"
defaultMessage="Define how to store and index documents."
/>
</p>
</EuiText>
</EuiFlexItem>
<EuiSpacer size="m" />
</div>
);
};
<EuiFlexItem grow={false}>
<EuiFlexGroup gutterSize="s">
<EuiFlexItem grow={false}>
<LoadMappingsFromJsonButton onJson={onJsonLoaded} />
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButtonEmpty
size="s"
flush="right"
href={documentationService.getMappingDocumentationLink()}
target="_blank"
iconType="help"
>
<FormattedMessage
id="xpack.idxMgmt.templateForm.stepMappings.docsButtonLabel"
defaultMessage="Mapping docs"
/>
</EuiButtonEmpty>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
</EuiFlexGroup>
<EuiSpacer size="s" />
{/* Mappings editor */}
<MappingsEditor
value={mappings}
onChange={onMappingsEditorUpdate}
indexSettings={indexSettings}
/>
<EuiSpacer size="m" />
</div>
);
}
);

View file

@ -0,0 +1,22 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React from 'react';
import { Forms } from '../../../../shared_imports';
import { WizardContent } from '../template_form';
import { StepMappings } from './step_mappings';
export const StepMappingsContainer = () => {
const { defaultValue, updateContent, getData } = Forms.useContent<WizardContent>('mappings');
return (
<StepMappings
defaultValue={defaultValue}
onChange={updateContent}
indexSettings={getData().settings}
/>
);
};

View file

@ -27,7 +27,7 @@ import {
serializeTemplate,
} from '../../../../../common/lib/template_serialization';
import { TemplateDeserialized, getTemplateParameter } from '../../../../../common';
import { StepProps } from '../types';
import { WizardSection } from '../template_form';
const { stripEmptyFields } = serializers;
@ -54,221 +54,228 @@ const getDescriptionText = (data: any) => {
);
};
export const StepReview: React.FunctionComponent<StepProps> = ({ template, updateCurrentStep }) => {
const {
name,
indexPatterns,
version,
order,
_kbnMeta: { isLegacy },
} = template!;
interface Props {
template: TemplateDeserialized;
navigateToStep: (stepId: WizardSection) => void;
}
const serializedTemplate = isLegacy
? serializeLegacyTemplate(
stripEmptyFields(template!, {
types: ['string'],
}) as TemplateDeserialized
)
: serializeTemplate(
stripEmptyFields(template!, {
types: ['string'],
}) as TemplateDeserialized
);
export const StepReview: React.FunctionComponent<Props> = React.memo(
({ template, navigateToStep }) => {
const {
name,
indexPatterns,
version,
order,
_kbnMeta: { isLegacy },
} = template!;
const serializedMappings = getTemplateParameter(serializedTemplate, 'mappings');
const serializedSettings = getTemplateParameter(serializedTemplate, 'settings');
const serializedAliases = getTemplateParameter(serializedTemplate, 'aliases');
const serializedTemplate = isLegacy
? serializeLegacyTemplate(
stripEmptyFields(template!, {
types: ['string'],
}) as TemplateDeserialized
)
: serializeTemplate(
stripEmptyFields(template!, {
types: ['string'],
}) as TemplateDeserialized
);
const numIndexPatterns = indexPatterns!.length;
const serializedMappings = getTemplateParameter(serializedTemplate, 'mappings');
const serializedSettings = getTemplateParameter(serializedTemplate, 'settings');
const serializedAliases = getTemplateParameter(serializedTemplate, 'aliases');
const hasWildCardIndexPattern = Boolean(indexPatterns!.find((pattern) => pattern === '*'));
const numIndexPatterns = indexPatterns!.length;
const SummaryTab = () => (
<div data-test-subj="summaryTab">
<EuiSpacer size="m" />
const hasWildCardIndexPattern = Boolean(indexPatterns!.find((pattern) => pattern === '*'));
<EuiFlexGroup>
<EuiFlexItem>
<EuiDescriptionList textStyle="reverse">
<EuiDescriptionListTitle>
<FormattedMessage
id="xpack.idxMgmt.templateForm.stepReview.summaryTab.indexPatternsLabel"
defaultMessage="Index {numIndexPatterns, plural, one {pattern} other {patterns}}"
values={{ numIndexPatterns }}
/>
</EuiDescriptionListTitle>
<EuiDescriptionListDescription>
{numIndexPatterns > 1 ? (
<EuiText>
<ul>
{indexPatterns!.map((indexName: string, i: number) => {
return (
<li key={`${indexName}-${i}`}>
<EuiTitle size="xs">
<span>{indexName}</span>
</EuiTitle>
</li>
);
})}
</ul>
</EuiText>
) : (
indexPatterns!.toString()
)}
</EuiDescriptionListDescription>
<EuiDescriptionListTitle>
<FormattedMessage
id="xpack.idxMgmt.templateForm.stepReview.summaryTab.orderLabel"
defaultMessage="Order"
/>
</EuiDescriptionListTitle>
<EuiDescriptionListDescription>
{order ? order : <NoneDescriptionText />}
</EuiDescriptionListDescription>
<EuiDescriptionListTitle>
<FormattedMessage
id="xpack.idxMgmt.templateForm.stepReview.summaryTab.versionLabel"
defaultMessage="Version"
/>
</EuiDescriptionListTitle>
<EuiDescriptionListDescription>
{version ? version : <NoneDescriptionText />}
</EuiDescriptionListDescription>
</EuiDescriptionList>
</EuiFlexItem>
<EuiFlexItem>
<EuiDescriptionList textStyle="reverse">
<EuiDescriptionListTitle>
<FormattedMessage
id="xpack.idxMgmt.templateForm.stepReview.summaryTab.settingsLabel"
defaultMessage="Index settings"
/>
</EuiDescriptionListTitle>
<EuiDescriptionListDescription>
{getDescriptionText(serializedSettings)}
</EuiDescriptionListDescription>
<EuiDescriptionListTitle>
<FormattedMessage
id="xpack.idxMgmt.templateForm.stepReview.summaryTab.mappingLabel"
defaultMessage="Mappings"
/>
</EuiDescriptionListTitle>
<EuiDescriptionListDescription>
{getDescriptionText(serializedMappings)}
</EuiDescriptionListDescription>
<EuiDescriptionListTitle>
<FormattedMessage
id="xpack.idxMgmt.templateForm.stepReview.summaryTab.aliasesLabel"
defaultMessage="Aliases"
/>
</EuiDescriptionListTitle>
<EuiDescriptionListDescription>
{getDescriptionText(serializedAliases)}
</EuiDescriptionListDescription>
</EuiDescriptionList>
</EuiFlexItem>
</EuiFlexGroup>
</div>
);
const RequestTab = () => {
const endpoint = `PUT _template/${name || '<templateName>'}`;
const templateString = JSON.stringify(serializedTemplate, null, 2);
const request = `${endpoint}\n${templateString}`;
// Beyond a certain point, highlighting the syntax will bog down performance to unacceptable
// levels. This way we prevent that happening for very large requests.
const language = request.length < 60000 ? 'json' : undefined;
return (
<div data-test-subj="requestTab">
const SummaryTab = () => (
<div data-test-subj="summaryTab">
<EuiSpacer size="m" />
<EuiText>
<p>
<FormattedMessage
id="xpack.idxMgmt.templateForm.stepReview.requestTab.descriptionText"
defaultMessage="This request will create the following index template."
/>
</p>
</EuiText>
<EuiFlexGroup>
<EuiFlexItem>
<EuiDescriptionList textStyle="reverse">
<EuiDescriptionListTitle>
<FormattedMessage
id="xpack.idxMgmt.templateForm.stepReview.summaryTab.indexPatternsLabel"
defaultMessage="Index {numIndexPatterns, plural, one {pattern} other {patterns}}"
values={{ numIndexPatterns }}
/>
</EuiDescriptionListTitle>
<EuiDescriptionListDescription>
{numIndexPatterns > 1 ? (
<EuiText>
<ul>
{indexPatterns!.map((indexName: string, i: number) => {
return (
<li key={`${indexName}-${i}`}>
<EuiTitle size="xs">
<span>{indexName}</span>
</EuiTitle>
</li>
);
})}
</ul>
</EuiText>
) : (
indexPatterns!.toString()
)}
</EuiDescriptionListDescription>
<EuiSpacer size="m" />
<EuiDescriptionListTitle>
<FormattedMessage
id="xpack.idxMgmt.templateForm.stepReview.summaryTab.orderLabel"
defaultMessage="Order"
/>
</EuiDescriptionListTitle>
<EuiDescriptionListDescription>
{order ? order : <NoneDescriptionText />}
</EuiDescriptionListDescription>
<EuiCodeBlock language={language} isCopyable>
{request}
</EuiCodeBlock>
<EuiDescriptionListTitle>
<FormattedMessage
id="xpack.idxMgmt.templateForm.stepReview.summaryTab.versionLabel"
defaultMessage="Version"
/>
</EuiDescriptionListTitle>
<EuiDescriptionListDescription>
{version ? version : <NoneDescriptionText />}
</EuiDescriptionListDescription>
</EuiDescriptionList>
</EuiFlexItem>
<EuiFlexItem>
<EuiDescriptionList textStyle="reverse">
<EuiDescriptionListTitle>
<FormattedMessage
id="xpack.idxMgmt.templateForm.stepReview.summaryTab.settingsLabel"
defaultMessage="Index settings"
/>
</EuiDescriptionListTitle>
<EuiDescriptionListDescription>
{getDescriptionText(serializedSettings)}
</EuiDescriptionListDescription>
<EuiDescriptionListTitle>
<FormattedMessage
id="xpack.idxMgmt.templateForm.stepReview.summaryTab.mappingLabel"
defaultMessage="Mappings"
/>
</EuiDescriptionListTitle>
<EuiDescriptionListDescription>
{getDescriptionText(serializedMappings)}
</EuiDescriptionListDescription>
<EuiDescriptionListTitle>
<FormattedMessage
id="xpack.idxMgmt.templateForm.stepReview.summaryTab.aliasesLabel"
defaultMessage="Aliases"
/>
</EuiDescriptionListTitle>
<EuiDescriptionListDescription>
{getDescriptionText(serializedAliases)}
</EuiDescriptionListDescription>
</EuiDescriptionList>
</EuiFlexItem>
</EuiFlexGroup>
</div>
);
};
return (
<div data-test-subj="stepSummary">
<EuiTitle>
<h2 data-test-subj="stepTitle">
<FormattedMessage
id="xpack.idxMgmt.templateForm.stepReview.stepTitle"
defaultMessage="Review details for '{templateName}'"
values={{ templateName: name }}
/>
</h2>
</EuiTitle>
const RequestTab = () => {
const endpoint = `PUT _template/${name || '<templateName>'}`;
const templateString = JSON.stringify(serializedTemplate, null, 2);
const request = `${endpoint}\n${templateString}`;
<EuiSpacer size="l" />
// Beyond a certain point, highlighting the syntax will bog down performance to unacceptable
// levels. This way we prevent that happening for very large requests.
const language = request.length < 60000 ? 'json' : undefined;
{hasWildCardIndexPattern ? (
<Fragment>
<EuiCallOut
title={
<FormattedMessage
id="xpack.idxMgmt.templateForm.stepReview.summaryTab.indexPatternsWarningTitle"
defaultMessage="This template uses a wildcard (*) as an index pattern."
/>
}
color="warning"
iconType="help"
data-test-subj="indexPatternsWarning"
>
<p data-test-subj="indexPatternsWarningDescription">
<FormattedMessage
id="xpack.idxMgmt.templateForm.stepReview.summaryTab.indexPatternsWarningDescription"
defaultMessage="All new indices that you create will use this template."
/>{' '}
{/* Edit link navigates back to step 1 (logistics) */}
<EuiLink onClick={updateCurrentStep.bind(null, 1)}>
<FormattedMessage
id="xpack.idxMgmt.templateForm.stepReview.summaryTab.indexPatternsWarningLinkText"
defaultMessage="Edit index patterns."
/>
</EuiLink>
</p>
</EuiCallOut>
return (
<div data-test-subj="requestTab">
<EuiSpacer size="m" />
</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 />,
},
]}
/>
</div>
);
};
<EuiText>
<p>
<FormattedMessage
id="xpack.idxMgmt.templateForm.stepReview.requestTab.descriptionText"
defaultMessage="This request will create the following index template."
/>
</p>
</EuiText>
<EuiSpacer size="m" />
<EuiCodeBlock language={language} isCopyable>
{request}
</EuiCodeBlock>
</div>
);
};
return (
<div data-test-subj="stepSummary">
<EuiTitle>
<h2 data-test-subj="stepTitle">
<FormattedMessage
id="xpack.idxMgmt.templateForm.stepReview.stepTitle"
defaultMessage="Review details for '{templateName}'"
values={{ templateName: name }}
/>
</h2>
</EuiTitle>
<EuiSpacer size="l" />
{hasWildCardIndexPattern ? (
<Fragment>
<EuiCallOut
title={
<FormattedMessage
id="xpack.idxMgmt.templateForm.stepReview.summaryTab.indexPatternsWarningTitle"
defaultMessage="This template uses a wildcard (*) as an index pattern."
/>
}
color="warning"
iconType="help"
data-test-subj="indexPatternsWarning"
>
<p data-test-subj="indexPatternsWarningDescription">
<FormattedMessage
id="xpack.idxMgmt.templateForm.stepReview.summaryTab.indexPatternsWarningDescription"
defaultMessage="All new indices that you create will use this template."
/>{' '}
{/* Edit link navigates back to step 1 (logistics) */}
<EuiLink onClick={navigateToStep.bind(null, 'logistics')}>
<FormattedMessage
id="xpack.idxMgmt.templateForm.stepReview.summaryTab.indexPatternsWarningLinkText"
defaultMessage="Edit index patterns."
/>
</EuiLink>
</p>
</EuiCallOut>
<EuiSpacer size="m" />
</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 />,
},
]}
/>
</div>
);
}
);

View file

@ -0,0 +1,26 @@
/*
* 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 { TemplateDeserialized } from '../../../../../common';
import { Forms } from '../../../../shared_imports';
import { WizardContent, WizardSection } from '../template_form';
import { StepReview } from './step_review';
interface Props {
getTemplateData: (wizardContent: WizardContent) => TemplateDeserialized;
}
export const StepReviewContainer = React.memo(({ getTemplateData }: Props) => {
const { navigateToStep } = Forms.useFormWizardContext<WizardSection>();
const { getData } = Forms.useMultiContentContext<WizardContent>();
const wizardContent = getData();
// Build the final template object, providing the wizard content data
const template = getTemplateData(wizardContent);
return <StepReview template={template} navigateToStep={navigateToStep} />;
});

View file

@ -18,111 +18,113 @@ import {
EuiCode,
} from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n/react';
import { Forms } from '../../../../shared_imports';
import { documentationService } from '../../../services/documentation';
import { StepProps } from '../types';
import { useJsonStep } from './use_json_step';
export const StepSettings: React.FunctionComponent<StepProps> = ({
template,
setDataGetter,
onStepValidityChange,
}) => {
const { content, setContent, error } = useJsonStep({
prop: 'settings',
defaultValue: template?.template.settings,
setDataGetter,
onStepValidityChange,
});
interface Props {
defaultValue: { [key: string]: any };
onChange: (content: Forms.Content) => void;
}
return (
<div data-test-subj="stepSettings">
<EuiFlexGroup justifyContent="spaceBetween">
<EuiFlexItem grow={false}>
<EuiTitle>
<h2 data-test-subj="stepTitle">
export const StepSettings: React.FunctionComponent<Props> = React.memo(
({ defaultValue, onChange }) => {
const { jsonContent, setJsonContent, error } = useJsonStep({
defaultValue,
onChange,
});
return (
<div data-test-subj="stepSettings">
<EuiFlexGroup justifyContent="spaceBetween">
<EuiFlexItem grow={false}>
<EuiTitle>
<h2 data-test-subj="stepTitle">
<FormattedMessage
id="xpack.idxMgmt.templateForm.stepSettings.stepTitle"
defaultMessage="Index settings (optional)"
/>
</h2>
</EuiTitle>
<EuiSpacer size="s" />
<EuiText>
<p>
<FormattedMessage
id="xpack.idxMgmt.templateForm.stepSettings.settingsDescription"
defaultMessage="Define the behavior of your indices."
/>
</p>
</EuiText>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButtonEmpty
size="s"
flush="right"
href={documentationService.getSettingsDocumentationLink()}
target="_blank"
iconType="help"
>
<FormattedMessage
id="xpack.idxMgmt.templateForm.stepSettings.stepTitle"
defaultMessage="Index settings (optional)"
id="xpack.idxMgmt.templateForm.stepSettings.docsButtonLabel"
defaultMessage="Index settings docs"
/>
</h2>
</EuiTitle>
</EuiButtonEmpty>
</EuiFlexItem>
</EuiFlexGroup>
<EuiSpacer size="s" />
<EuiSpacer size="l" />
<EuiText>
<p>
<FormattedMessage
id="xpack.idxMgmt.templateForm.stepSettings.settingsDescription"
defaultMessage="Define the behavior of your indices."
/>
</p>
</EuiText>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButtonEmpty
size="s"
flush="right"
href={documentationService.getSettingsDocumentationLink()}
target="_blank"
iconType="help"
>
{/* Settings code editor */}
<EuiFormRow
label={
<FormattedMessage
id="xpack.idxMgmt.templateForm.stepSettings.docsButtonLabel"
defaultMessage="Index settings docs"
id="xpack.idxMgmt.templateForm.stepSettings.fieldIndexSettingsLabel"
defaultMessage="Index settings"
/>
</EuiButtonEmpty>
</EuiFlexItem>
</EuiFlexGroup>
<EuiSpacer size="l" />
{/* Settings code editor */}
<EuiFormRow
label={
<FormattedMessage
id="xpack.idxMgmt.templateForm.stepSettings.fieldIndexSettingsLabel"
defaultMessage="Index settings"
/>
}
helpText={
<FormattedMessage
id="xpack.idxMgmt.templateForm.stepSettings.settingsEditorHelpText"
defaultMessage="Use JSON format: {code}"
values={{
code: <EuiCode>{JSON.stringify({ number_of_replicas: 1 })}</EuiCode>,
}
helpText={
<FormattedMessage
id="xpack.idxMgmt.templateForm.stepSettings.settingsEditorHelpText"
defaultMessage="Use JSON format: {code}"
values={{
code: <EuiCode>{JSON.stringify({ number_of_replicas: 1 })}</EuiCode>,
}}
/>
}
isInvalid={Boolean(error)}
error={error}
fullWidth
>
<EuiCodeEditor
mode="json"
theme="textmate"
width="100%"
height="500px"
setOptions={{
showLineNumbers: false,
tabSize: 2,
}}
editorProps={{
$blockScrolling: Infinity,
}}
showGutter={false}
minLines={6}
aria-label={i18n.translate(
'xpack.idxMgmt.templateForm.stepSettings.fieldIndexSettingsAriaLabel',
{
defaultMessage: 'Index settings editor',
}
)}
value={jsonContent}
onChange={setJsonContent}
data-test-subj="settingsEditor"
/>
}
isInvalid={Boolean(error)}
error={error}
fullWidth
>
<EuiCodeEditor
mode="json"
theme="textmate"
width="100%"
height="500px"
setOptions={{
showLineNumbers: false,
tabSize: 2,
}}
editorProps={{
$blockScrolling: Infinity,
}}
showGutter={false}
minLines={6}
aria-label={i18n.translate(
'xpack.idxMgmt.templateForm.stepSettings.fieldIndexSettingsAriaLabel',
{
defaultMessage: 'Index settings editor',
}
)}
value={content}
onChange={(updated: string) => setContent(updated)}
data-test-subj="settingsEditor"
/>
</EuiFormRow>
</div>
);
};
</EuiFormRow>
</div>
);
}
);

View file

@ -0,0 +1,16 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React from 'react';
import { Forms } from '../../../../shared_imports';
import { WizardContent } from '../template_form';
import { StepSettings } from './step_settings';
export const StepSettingsContainer = React.memo(() => {
const { defaultValue, updateContent } = Forms.useContent<WizardContent>('settings');
return <StepSettings defaultValue={defaultValue} onChange={updateContent} />;
});

View file

@ -7,31 +7,23 @@
import { useEffect, useState, useCallback } from 'react';
import { i18n } from '@kbn/i18n';
import { isJSON } from '../../../../shared_imports';
import { StepProps, DataGetterFunc } from '../types';
import { isJSON, Forms } from '../../../../shared_imports';
interface Parameters {
prop: 'settings' | 'mappings' | 'aliases';
setDataGetter: StepProps['setDataGetter'];
onStepValidityChange: StepProps['onStepValidityChange'];
onChange: (content: Forms.Content) => void;
defaultValue?: object;
}
const stringifyJson = (json: any) =>
Object.keys(json).length ? JSON.stringify(json, null, 2) : '{\n\n}';
export const useJsonStep = ({
prop,
defaultValue = {},
setDataGetter,
onStepValidityChange,
}: Parameters) => {
const [content, setContent] = useState<string>(stringifyJson(defaultValue));
export const useJsonStep = ({ defaultValue, onChange }: Parameters) => {
const [jsonContent, setJsonContent] = useState<string>(stringifyJson(defaultValue ?? {}));
const [error, setError] = useState<string | null>(null);
const validateContent = useCallback(() => {
// We allow empty string as it will be converted to "{}""
const isValid = content.trim() === '' ? true : isJSON(content);
const isValid = jsonContent.trim() === '' ? true : isJSON(jsonContent);
if (!isValid) {
setError(
i18n.translate('xpack.idxMgmt.validators.string.invalidJSONError', {
@ -42,26 +34,28 @@ export const useJsonStep = ({
setError(null);
}
return isValid;
}, [content]);
const dataGetter = useCallback<DataGetterFunc>(() => {
const isValid = validateContent();
const value = isValid && content.trim() !== '' ? JSON.parse(content) : {};
// If no key has been added to the JSON object, we strip it out so an empty object is not sent in the request
const data = { [prop]: Object.keys(value).length > 0 ? value : undefined };
return Promise.resolve({ isValid, data, path: 'template' });
}, [content, validateContent, prop]);
}, [jsonContent]);
useEffect(() => {
const isValid = validateContent();
onStepValidityChange(isValid);
setDataGetter(dataGetter);
}, [content, dataGetter, onStepValidityChange, setDataGetter, validateContent]);
const getData = () => {
const value = isValid && jsonContent.trim() !== '' ? JSON.parse(jsonContent) : {};
// If no key has been added to the JSON object, we strip it out so an empty object is not sent in the request
return Object.keys(value).length > 0 ? value : undefined;
};
const content = {
isValid,
validate: async () => isValid,
getData,
};
onChange(content);
}, [jsonContent, onChange, validateContent]);
return {
content,
setContent,
jsonContent,
setJsonContent,
error,
};
};

View file

@ -3,25 +3,24 @@
* 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, { Fragment, useState, useRef, useCallback } from 'react';
import React, { useCallback } from 'react';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n/react';
import {
EuiButton,
EuiButtonEmpty,
EuiFlexGroup,
EuiFlexItem,
EuiForm,
EuiSpacer,
} from '@elastic/eui';
import { EuiSpacer } from '@elastic/eui';
import { serializers } from '../../../shared_imports';
import { TemplateDeserialized, CREATE_LEGACY_TEMPLATE_BY_DEFAULT } from '../../../../common';
import { TemplateSteps } from './template_steps';
import { StepAliases, StepLogistics, StepMappings, StepSettings, StepReview } from './steps';
import { StepProps, DataGetterFunc } from './types';
import { serializers, Forms } from '../../../shared_imports';
import { SectionError } from '../section_error';
import {
StepLogisticsContainer,
StepSettingsContainer,
StepMappingsContainer,
StepAliasesContainer,
StepReviewContainer,
} from './steps';
const { stripEmptyFields } = serializers;
const { FormWizard, FormWizardStep } = Forms;
interface Props {
onSave: (template: TemplateDeserialized) => void;
@ -32,244 +31,172 @@ interface Props {
isEditing?: boolean;
}
interface ValidationState {
[key: number]: { isValid: boolean | undefined };
export interface WizardContent {
logistics: Omit<TemplateDeserialized, '_kbnMeta' | 'template'>;
settings: TemplateDeserialized['template']['settings'];
mappings: TemplateDeserialized['template']['mappings'];
aliases: TemplateDeserialized['template']['aliases'];
}
const defaultValidation = { isValid: true };
export type WizardSection = keyof WizardContent | 'review';
const stepComponentMap: { [key: number]: React.FunctionComponent<StepProps> } = {
1: StepLogistics,
2: StepSettings,
3: StepMappings,
4: StepAliases,
5: StepReview,
const wizardSections: { [id: string]: { id: WizardSection; label: string } } = {
logistics: {
id: 'logistics',
label: i18n.translate('xpack.idxMgmt.templateForm.steps.logisticsStepName', {
defaultMessage: 'Logistics',
}),
},
settings: {
id: 'settings',
label: i18n.translate('xpack.idxMgmt.templateForm.steps.settingsStepName', {
defaultMessage: 'Index settings',
}),
},
mappings: {
id: 'mappings',
label: i18n.translate('xpack.idxMgmt.templateForm.steps.mappingsStepName', {
defaultMessage: 'Mappings',
}),
},
aliases: {
id: 'aliases',
label: i18n.translate('xpack.idxMgmt.templateForm.steps.aliasesStepName', {
defaultMessage: 'Aliases',
}),
},
review: {
id: 'review',
label: i18n.translate('xpack.idxMgmt.templateForm.steps.summaryStepName', {
defaultMessage: 'Review template',
}),
},
};
export const TemplateForm: React.FunctionComponent<Props> = ({
export const TemplateForm = ({
defaultValue = {
name: '',
indexPatterns: [],
template: {},
template: {
settings: {},
mappings: {},
aliases: {},
},
_kbnMeta: {
isManaged: false,
isLegacy: CREATE_LEGACY_TEMPLATE_BY_DEFAULT,
},
},
onSave,
isEditing,
isSaving,
saveError,
clearSaveError,
isEditing,
}) => {
const [currentStep, setCurrentStep] = useState<number>(1);
const [validation, setValidation] = useState<ValidationState>({
1: defaultValidation,
2: defaultValidation,
3: defaultValidation,
4: defaultValidation,
5: defaultValidation,
onSave,
}: Props) => {
const {
template: { settings, mappings, aliases },
_kbnMeta,
...logistics
} = defaultValue;
const wizardDefaultValue: WizardContent = {
logistics,
settings,
mappings,
aliases,
};
const i18nTexts = {
save: isEditing ? (
<FormattedMessage
id="xpack.idxMgmt.templateForm.saveButtonLabel"
defaultMessage="Save template"
/>
) : (
<FormattedMessage
id="xpack.idxMgmt.templateForm.createButtonLabel"
defaultMessage="Create template"
/>
),
};
const apiError = saveError ? (
<>
<SectionError
title={
<FormattedMessage
id="xpack.idxMgmt.templateForm.saveTemplateError"
defaultMessage="Unable to create template"
/>
}
error={saveError}
data-test-subj="saveTemplateError"
/>
<EuiSpacer size="m" />
</>
) : null;
const buildTemplateObject = (initialTemplate: TemplateDeserialized) => (
wizardData: WizardContent
): TemplateDeserialized => ({
...initialTemplate,
...wizardData.logistics,
template: {
settings: wizardData.settings,
mappings: wizardData.mappings,
aliases: wizardData.aliases,
},
});
const template = useRef<TemplateDeserialized>(defaultValue);
const stepsDataGetters = useRef<Record<number, DataGetterFunc>>({});
const onSaveTemplate = useCallback(
async (wizardData: WizardContent) => {
const template = buildTemplateObject(defaultValue)(wizardData);
const lastStep = Object.keys(stepComponentMap).length;
const CurrentStepComponent = stepComponentMap[currentStep];
const isStepValid = validation[currentStep].isValid;
// We need to strip empty string, otherwise if the "order" or "version"
// are not set, they will be empty string and ES expect a number for those parameters.
onSave(
stripEmptyFields(template, {
types: ['string'],
}) as TemplateDeserialized
);
const setStepDataGetter = useCallback(
(stepDataGetter: DataGetterFunc) => {
stepsDataGetters.current[currentStep] = stepDataGetter;
clearSaveError();
},
[currentStep]
);
const onStepValidityChange = useCallback(
(isValid: boolean | undefined) => {
setValidation((prev) => ({
...prev,
[currentStep]: {
isValid,
errors: {},
},
}));
},
[currentStep]
);
const validateAndGetDataFromCurrentStep = async () => {
const validateAndGetStepData = stepsDataGetters.current[currentStep];
if (!validateAndGetStepData) {
throw new Error(`No data getter has been set for step "${currentStep}"`);
}
const { isValid, data, path } = await validateAndGetStepData();
if (isValid) {
// Update the template object with the current step data
if (path) {
// We only update a "slice" of the template
const sliceToUpdate = template.current[path as keyof TemplateDeserialized];
if (sliceToUpdate === null || typeof sliceToUpdate !== 'object') {
return { isValid, data };
}
template.current = {
...template.current,
[path]: { ...sliceToUpdate, ...data },
};
} else {
template.current = { ...template.current, ...data };
}
}
return { isValid, data };
};
const updateCurrentStep = async (nextStep: number) => {
// All steps needs validation, except for the last step
const shouldValidate = currentStep !== lastStep;
if (shouldValidate) {
const isValid =
isStepValid === false ? false : (await validateAndGetDataFromCurrentStep()).isValid;
// If step is invalid do not let user proceed
if (!isValid) {
return;
}
}
setCurrentStep(nextStep);
clearSaveError();
};
const onBack = () => {
const prevStep = currentStep - 1;
updateCurrentStep(prevStep);
};
const onNext = () => {
const nextStep = currentStep + 1;
updateCurrentStep(nextStep);
};
const saveButtonLabel = isEditing ? (
<FormattedMessage
id="xpack.idxMgmt.templateForm.saveButtonLabel"
defaultMessage="Save template"
/>
) : (
<FormattedMessage
id="xpack.idxMgmt.templateForm.createButtonLabel"
defaultMessage="Create template"
/>
[defaultValue, onSave, clearSaveError]
);
return (
<Fragment>
<TemplateSteps
currentStep={currentStep}
updateCurrentStep={updateCurrentStep}
isCurrentStepValid={isStepValid}
/>
<FormWizard<WizardContent>
defaultValue={wizardDefaultValue}
onSave={onSaveTemplate}
isEditing={isEditing}
isSaving={isSaving}
apiError={apiError}
texts={i18nTexts}
>
<FormWizardStep
id={wizardSections.logistics.id}
label={wizardSections.logistics.label}
isRequired
>
<StepLogisticsContainer isEditing={isEditing} />
</FormWizardStep>
<EuiSpacer size="l" />
<FormWizardStep id={wizardSections.settings.id} label={wizardSections.settings.label}>
<StepSettingsContainer />
</FormWizardStep>
{saveError ? (
<Fragment>
<SectionError
title={
<FormattedMessage
id="xpack.idxMgmt.templateForm.saveTemplateError"
defaultMessage="Unable to create template"
/>
}
error={saveError}
data-test-subj="saveTemplateError"
/>
<EuiSpacer size="m" />
</Fragment>
) : null}
<FormWizardStep id={wizardSections.mappings.id} label={wizardSections.mappings.label}>
<StepMappingsContainer />
</FormWizardStep>
<EuiForm data-test-subj="templateForm">
<CurrentStepComponent
key={currentStep}
template={template.current}
setDataGetter={setStepDataGetter}
updateCurrentStep={updateCurrentStep}
onStepValidityChange={onStepValidityChange}
isEditing={isEditing}
/>
<EuiSpacer size="l" />
<FormWizardStep id={wizardSections.aliases.id} label={wizardSections.aliases.label}>
<StepAliasesContainer />
</FormWizardStep>
<EuiFlexGroup justifyContent="spaceBetween">
<EuiFlexItem grow={false}>
<EuiFlexGroup>
{currentStep > 1 ? (
<EuiFlexItem grow={false}>
<EuiButtonEmpty iconType="arrowLeft" onClick={onBack} data-test-subj="backButton">
<FormattedMessage
id="xpack.idxMgmt.templateForm.backButtonLabel"
defaultMessage="Back"
/>
</EuiButtonEmpty>
</EuiFlexItem>
) : null}
{currentStep < lastStep ? (
<EuiFlexItem grow={false}>
<EuiButton
fill
iconType="arrowRight"
onClick={onNext}
iconSide="right"
disabled={isStepValid === false}
data-test-subj="nextButton"
>
<FormattedMessage
id="xpack.idxMgmt.templateForm.nextButtonLabel"
defaultMessage="Next"
/>
</EuiButton>
</EuiFlexItem>
) : null}
{currentStep === lastStep ? (
<EuiFlexItem grow={false}>
<EuiButton
fill
color="secondary"
iconType="check"
onClick={onSave.bind(
null,
stripEmptyFields(template.current!, {
types: ['string'],
}) as TemplateDeserialized
)}
data-test-subj="submitButton"
isLoading={isSaving}
>
{isSaving ? (
<FormattedMessage
id="xpack.idxMgmt.templateForm.savingButtonLabel"
defaultMessage="Saving..."
/>
) : (
saveButtonLabel
)}
</EuiButton>
</EuiFlexItem>
) : null}
</EuiFlexGroup>
</EuiFlexItem>
</EuiFlexGroup>
</EuiForm>
<EuiSpacer size="m" />
</Fragment>
<FormWizardStep id={wizardSections.review.id} label={wizardSections.review.label}>
<StepReviewContainer getTemplateData={buildTemplateObject(defaultValue)} />
</FormWizardStep>
</FormWizard>
);
};

View file

@ -1,51 +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 from 'react';
import { EuiStepsHorizontal } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
interface Props {
currentStep: number;
updateCurrentStep: (step: number, maxCompletedStep: number) => void;
isCurrentStepValid: boolean | undefined;
}
const stepNamesMap: { [key: number]: string } = {
1: i18n.translate('xpack.idxMgmt.templateForm.steps.logisticsStepName', {
defaultMessage: 'Logistics',
}),
2: i18n.translate('xpack.idxMgmt.templateForm.steps.settingsStepName', {
defaultMessage: 'Index settings',
}),
3: i18n.translate('xpack.idxMgmt.templateForm.steps.mappingsStepName', {
defaultMessage: 'Mappings',
}),
4: i18n.translate('xpack.idxMgmt.templateForm.steps.aliasesStepName', {
defaultMessage: 'Aliases',
}),
5: i18n.translate('xpack.idxMgmt.templateForm.steps.summaryStepName', {
defaultMessage: 'Review template',
}),
};
export const TemplateSteps: React.FunctionComponent<Props> = ({
currentStep,
updateCurrentStep,
isCurrentStepValid,
}) => {
const steps = [1, 2, 3, 4, 5].map((step) => {
return {
title: stepNamesMap[step],
isComplete: currentStep > step,
isSelected: currentStep === step,
disabled: step !== currentStep && isCurrentStepValid === false,
onClick: () => updateCurrentStep(step, step - 1),
};
});
return <EuiStepsHorizontal steps={steps} />;
};

View file

@ -1,24 +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 { TemplateDeserialized } from '../../../../common';
export interface StepProps {
template?: TemplateDeserialized;
setDataGetter: (dataGetter: DataGetterFunc) => void;
updateCurrentStep: (step: number) => void;
onStepValidityChange: (isValid: boolean | undefined) => void;
isEditing?: boolean;
}
export type DataGetterFunc = () => Promise<{
/** Is the step data valid or not */
isValid: boolean;
/** The current step data (can be invalid) */
data: any;
/** Optional "slice" of the complete object the step is updating */
path?: string;
}>;

View file

@ -10,6 +10,7 @@ export {
UseRequestConfig,
sendRequest,
useRequest,
Forms,
} from '../../../../src/plugins/es_ui_shared/public/';
export {

View file

@ -7080,12 +7080,9 @@
"xpack.idxMgmt.templateEdit.managedTemplateWarningTitle": "マネジドテンプレートの編集は許可されていません。",
"xpack.idxMgmt.templateEdit.systemTemplateWarningDescription": "システムテンプレートは内部オペレーションに不可欠です。",
"xpack.idxMgmt.templateEdit.systemTemplateWarningTitle": "システムテンプレートを編集することで、Kibana に重大な障害が生じる可能性があります",
"xpack.idxMgmt.templateForm.backButtonLabel": "戻る",
"xpack.idxMgmt.templateForm.createButtonLabel": "テンプレートを作成",
"xpack.idxMgmt.templateForm.nextButtonLabel": "次へ",
"xpack.idxMgmt.templateForm.saveButtonLabel": "テンプレートを保存",
"xpack.idxMgmt.templateForm.saveTemplateError": "テンプレートを作成できません",
"xpack.idxMgmt.templateForm.savingButtonLabel": "保存中…",
"xpack.idxMgmt.templateForm.stepAliases.aliasesDescription": "エイリアスをセットアップして、インデックスに関連付けてください。",
"xpack.idxMgmt.templateForm.stepAliases.aliasesEditorHelpText": "JSON フォーマットを使用: {code}",
"xpack.idxMgmt.templateForm.stepAliases.docsButtonLabel": "インデックステンプレートドキュメント",

View file

@ -7084,12 +7084,9 @@
"xpack.idxMgmt.templateEdit.managedTemplateWarningTitle": "不允许编辑托管模板",
"xpack.idxMgmt.templateEdit.systemTemplateWarningDescription": "系统模板对内部操作至关重要。",
"xpack.idxMgmt.templateEdit.systemTemplateWarningTitle": "编辑系统模板会使 Kibana 无法运行",
"xpack.idxMgmt.templateForm.backButtonLabel": "上一步",
"xpack.idxMgmt.templateForm.createButtonLabel": "创建模板",
"xpack.idxMgmt.templateForm.nextButtonLabel": "下一步",
"xpack.idxMgmt.templateForm.saveButtonLabel": "保存模板",
"xpack.idxMgmt.templateForm.saveTemplateError": "无法创建模板",
"xpack.idxMgmt.templateForm.savingButtonLabel": "正在保存……",
"xpack.idxMgmt.templateForm.stepAliases.aliasesDescription": "设置要与索引关联的别名。",
"xpack.idxMgmt.templateForm.stepAliases.aliasesEditorHelpText": "使用 JSON 格式:{code}",
"xpack.idxMgmt.templateForm.stepAliases.docsButtonLabel": "索引模板文档",