[Index template] Refactor index template wizard (#69037)
Co-authored-by: Alison Goryachev <alisonmllr20@gmail.com>
This commit is contained in:
parent
c58f4d54a1
commit
5e8d824a64
45
src/plugins/es_ui_shared/public/forms/form_wizard/README.md
Normal file
45
src/plugins/es_ui_shared/public/forms/form_wizard/README.md
Normal 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.
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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>;
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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;
|
||||
};
|
32
src/plugins/es_ui_shared/public/forms/form_wizard/index.ts
Normal file
32
src/plugins/es_ui_shared/public/forms/form_wizard/index.ts
Normal 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';
|
22
src/plugins/es_ui_shared/public/forms/index.ts
Normal file
22
src/plugins/es_ui_shared/public/forms/index.ts
Normal 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';
|
157
src/plugins/es_ui_shared/public/forms/multi_content/README.md
Normal file
157
src/plugins/es_ui_shared/public/forms/multi_content/README.md
Normal 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.
|
29
src/plugins/es_ui_shared/public/forms/multi_content/index.ts
Normal file
29
src/plugins/es_ui_shared/public/forms/multi_content/index.ts
Normal 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';
|
|
@ -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,
|
||||
};
|
||||
}
|
|
@ -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,
|
||||
};
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
}
|
|
@ -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 {
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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' });
|
||||
|
|
|
@ -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' });
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -11,3 +11,5 @@ export * from './mappings_editor';
|
|||
export * from './components/load_mappings';
|
||||
|
||||
export { OnUpdateHandler, Types } from './mappings_state';
|
||||
|
||||
export { IndexSettings } from './types';
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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,
|
||||
});
|
||||
|
|
|
@ -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.`);
|
||||
}
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
|
|
@ -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} />;
|
||||
};
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
|
|
@ -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} />
|
||||
);
|
||||
};
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
|
|
@ -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} />;
|
||||
});
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
|
|
@ -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} />;
|
||||
});
|
|
@ -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,
|
||||
};
|
||||
};
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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} />;
|
||||
};
|
|
@ -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;
|
||||
}>;
|
|
@ -10,6 +10,7 @@ export {
|
|||
UseRequestConfig,
|
||||
sendRequest,
|
||||
useRequest,
|
||||
Forms,
|
||||
} from '../../../../src/plugins/es_ui_shared/public/';
|
||||
|
||||
export {
|
||||
|
|
|
@ -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": "インデックステンプレートドキュメント",
|
||||
|
|
|
@ -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": "索引模板文档",
|
||||
|
|
Loading…
Reference in a new issue