From 181c1cba6015d60023a3597ec3a70e9c482d8c1f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Loix?= Date: Thu, 18 Jun 2020 21:52:24 +0200 Subject: [PATCH] [7.x] [Index template] Refactor index template wizard (#69037) (#69535) Co-authored-by: Alison Goryachev --- .../public/forms/form_wizard/README.md | 45 ++ .../public/forms/form_wizard/form_wizard.tsx | 139 ++++++ .../forms/form_wizard/form_wizard_context.tsx | 173 ++++++++ .../forms/form_wizard/form_wizard_nav.tsx | 105 +++++ .../forms/form_wizard/form_wizard_step.tsx | 39 ++ .../public/forms/form_wizard/index.ts | 32 ++ .../es_ui_shared/public/forms/index.ts | 22 + .../public/forms/multi_content/README.md | 157 +++++++ .../public/forms/multi_content/index.ts | 29 ++ .../multi_content/multi_content_context.tsx | 79 ++++ .../forms/multi_content/use_multi_content.ts | 215 +++++++++ .../multi_content/with_multi_content.tsx | 40 ++ src/plugins/es_ui_shared/public/index.ts | 8 + .../configuration_form/configuration_form.tsx | 2 +- .../fields/create_field/create_field.tsx | 2 +- .../edit_field/edit_field_container.tsx | 2 +- .../document_fields/fields_tree_editor.tsx | 11 +- .../templates_form/templates_form.tsx | 2 +- .../components/mappings_editor/index.ts | 1 + .../index_settings_context.tsx | 2 +- .../mappings_editor/lib/utils.test.ts | 56 +-- .../components/mappings_editor/lib/utils.ts | 19 - .../mappings_editor/mappings_state.tsx | 44 +- .../components/mappings_editor/reducer.ts | 25 +- .../components/template_form/steps/index.ts | 10 +- .../template_form/steps/step_aliases.tsx | 206 ++++----- .../steps/step_aliases_container.tsx | 16 + .../template_form/steps/step_logistics.tsx | 183 ++++---- .../steps/step_logistics_container.tsx | 22 + .../template_form/steps/step_mappings.tsx | 170 ++++---- .../steps/step_mappings_container.tsx | 22 + .../template_form/steps/step_review.tsx | 407 +++++++++--------- .../steps/step_review_container.tsx | 26 ++ .../template_form/steps/step_settings.tsx | 192 +++++---- .../steps/step_settings_container.tsx | 16 + .../template_form/steps/use_json_step.ts | 48 +-- .../template_form/template_form.tsx | 381 +++++++--------- .../template_form/template_steps.tsx | 51 --- .../components/template_form/types.ts | 24 -- .../index_management/public/shared_imports.ts | 1 + .../translations/translations/ja-JP.json | 3 - .../translations/translations/zh-CN.json | 3 - 42 files changed, 1990 insertions(+), 1040 deletions(-) create mode 100644 src/plugins/es_ui_shared/public/forms/form_wizard/README.md create mode 100644 src/plugins/es_ui_shared/public/forms/form_wizard/form_wizard.tsx create mode 100644 src/plugins/es_ui_shared/public/forms/form_wizard/form_wizard_context.tsx create mode 100644 src/plugins/es_ui_shared/public/forms/form_wizard/form_wizard_nav.tsx create mode 100644 src/plugins/es_ui_shared/public/forms/form_wizard/form_wizard_step.tsx create mode 100644 src/plugins/es_ui_shared/public/forms/form_wizard/index.ts create mode 100644 src/plugins/es_ui_shared/public/forms/index.ts create mode 100644 src/plugins/es_ui_shared/public/forms/multi_content/README.md create mode 100644 src/plugins/es_ui_shared/public/forms/multi_content/index.ts create mode 100644 src/plugins/es_ui_shared/public/forms/multi_content/multi_content_context.tsx create mode 100644 src/plugins/es_ui_shared/public/forms/multi_content/use_multi_content.ts create mode 100644 src/plugins/es_ui_shared/public/forms/multi_content/with_multi_content.tsx create mode 100644 x-pack/plugins/index_management/public/application/components/template_form/steps/step_aliases_container.tsx create mode 100644 x-pack/plugins/index_management/public/application/components/template_form/steps/step_logistics_container.tsx create mode 100644 x-pack/plugins/index_management/public/application/components/template_form/steps/step_mappings_container.tsx create mode 100644 x-pack/plugins/index_management/public/application/components/template_form/steps/step_review_container.tsx create mode 100644 x-pack/plugins/index_management/public/application/components/template_form/steps/step_settings_container.tsx delete mode 100644 x-pack/plugins/index_management/public/application/components/template_form/template_steps.tsx delete mode 100644 x-pack/plugins/index_management/public/application/components/template_form/types.ts diff --git a/src/plugins/es_ui_shared/public/forms/form_wizard/README.md b/src/plugins/es_ui_shared/public/forms/form_wizard/README.md new file mode 100644 index 0000000000000..56c792b89049b --- /dev/null +++ b/src/plugins/es_ui_shared/public/forms/form_wizard/README.md @@ -0,0 +1,45 @@ +# FormWizard + +The `` and `` 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 `` steps as well as the "Back" and "Next" button. + +Let's see it through an example + +```js +const MyForm = () => { + return ( + + 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. + > + +
+ Here you can put anything... but you probably want to put a Container from the + MultiContent example above. +
+
+ + +
+ Here you can put anything... but you probably want to put a Container from the + MultiContent example above. +
+
+ + +
+ Here you can put anything... but you probably want to put a Container from the + MultiContent example above. +
+
+
+ ); +}; +``` + +That's all we need to build a multi-step form wizard, making sure the data is cached when switching steps. \ No newline at end of file diff --git a/src/plugins/es_ui_shared/public/forms/form_wizard/form_wizard.tsx b/src/plugins/es_ui_shared/public/forms/form_wizard/form_wizard.tsx new file mode 100644 index 0000000000000..cdb332e9e9130 --- /dev/null +++ b/src/plugins/es_ui_shared/public/forms/form_wizard/form_wizard.tsx @@ -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 extends ProviderProps { + isSaving?: boolean; + apiError: JSX.Element | null; + texts?: Partial; +} + +export function FormWizard({ + texts, + defaultActiveStep, + defaultValue, + apiError, + isEditing, + isSaving, + onSave, + onChange, + children, +}: Props) { + return ( + + defaultValue={defaultValue} + isEditing={isEditing} + onSave={onSave} + onChange={onChange} + defaultActiveStep={defaultActiveStep} + > + + {({ 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 */} + + + + + {/* Any possible API error when saving/updating */} + {apiError} + + {/* Active step content */} + {children} + + + + {/* Button navigation */} + + + ); + }} + + + ); +} diff --git a/src/plugins/es_ui_shared/public/forms/form_wizard/form_wizard_context.tsx b/src/plugins/es_ui_shared/public/forms/form_wizard/form_wizard_context.tsx new file mode 100644 index 0000000000000..5667220881df2 --- /dev/null +++ b/src/plugins/es_ui_shared/public/forms/form_wizard/form_wizard_context.tsx @@ -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 { + onSave: (data: T) => void | Promise; + children: JSX.Element | JSX.Element[]; + isEditing?: boolean; + defaultActiveStep?: number; + defaultValue?: HookProps['defaultValue']; + onChange?: HookProps['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 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({} as Context); + +export const FormWizardProvider = WithMultiContent>(function FormWizardProvider< + T extends object = { [key: string]: any } +>({ children, defaultActiveStep = 0, isEditing, onSave }: Props) { + const { getData, validate, validation } = useMultiContentContext(); + + const [state, setState] = useState({ + 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 {children}; +}); + +export const FormWizardConsumer = formWizardContext.Consumer; + +export function useFormWizardContext() { + const ctx = useContext(formWizardContext); + if (ctx === undefined) { + throw new Error('useFormWizardContext() must be called within a '); + } + return ctx as Context; +} diff --git a/src/plugins/es_ui_shared/public/forms/form_wizard/form_wizard_nav.tsx b/src/plugins/es_ui_shared/public/forms/form_wizard/form_wizard_nav.tsx new file mode 100644 index 0000000000000..3e0e9cf897b5d --- /dev/null +++ b/src/plugins/es_ui_shared/public/forms/form_wizard/form_wizard_nav.tsx @@ -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; +} + +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 ( + + + + {/* Back button */} + {activeStepIndex > 0 ? ( + + + {labels.back} + + + ) : null} + + {/* Next button */} + + + {nextButtonLabel} + + + + + + ); +}; diff --git a/src/plugins/es_ui_shared/public/forms/form_wizard/form_wizard_step.tsx b/src/plugins/es_ui_shared/public/forms/form_wizard/form_wizard_step.tsx new file mode 100644 index 0000000000000..c073c188a6ad6 --- /dev/null +++ b/src/plugins/es_ui_shared/public/forms/form_wizard/form_wizard_step.tsx @@ -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; +}; diff --git a/src/plugins/es_ui_shared/public/forms/form_wizard/index.ts b/src/plugins/es_ui_shared/public/forms/form_wizard/index.ts new file mode 100644 index 0000000000000..b1cb11735a110 --- /dev/null +++ b/src/plugins/es_ui_shared/public/forms/form_wizard/index.ts @@ -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'; diff --git a/src/plugins/es_ui_shared/public/forms/index.ts b/src/plugins/es_ui_shared/public/forms/index.ts new file mode 100644 index 0000000000000..96140c9b46185 --- /dev/null +++ b/src/plugins/es_ui_shared/public/forms/index.ts @@ -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'; diff --git a/src/plugins/es_ui_shared/public/forms/multi_content/README.md b/src/plugins/es_ui_shared/public/forms/multi_content/README.md new file mode 100644 index 0000000000000..08c37c20b5bf6 --- /dev/null +++ b/src/plugins/es_ui_shared/public/forms/multi_content/README.md @@ -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 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(); + + const totalSteps = 3; + const [currentStep, setCurrentStep] = useState(0); + + const renderContent = () => { + switch (currentStep) { + case 0: + return ; + case 1: + return ; + case 2: + return ; + } + }; + + 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 */} + + Next + + + ); +}); +``` + +```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('contentOne'); + + return +}; +``` + +```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 ( + setInternalStateValue(e.target.value)} /> + ); +} +``` + +And just like that, `` 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. \ No newline at end of file diff --git a/src/plugins/es_ui_shared/public/forms/multi_content/index.ts b/src/plugins/es_ui_shared/public/forms/multi_content/index.ts new file mode 100644 index 0000000000000..a7df0e386d173 --- /dev/null +++ b/src/plugins/es_ui_shared/public/forms/multi_content/index.ts @@ -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'; diff --git a/src/plugins/es_ui_shared/public/forms/multi_content/multi_content_context.tsx b/src/plugins/es_ui_shared/public/forms/multi_content/multi_content_context.tsx new file mode 100644 index 0000000000000..5fbe3d2bbbdd4 --- /dev/null +++ b/src/plugins/es_ui_shared/public/forms/multi_content/multi_content_context.tsx @@ -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>({} as MultiContent); + +interface Props extends HookProps { + children: JSX.Element | JSX.Element[]; +} + +export function MultiContentProvider({ + defaultValue, + onChange, + children, +}: Props) { + const multiContent = useMultiContent({ defaultValue, onChange }); + + return ( + {children} + ); +} + +export const MultiContentConsumer = multiContentContext.Consumer; + +export function useMultiContentContext() { + const ctx = useContext(multiContentContext); + if (Object.keys(ctx).length === 0) { + throw new Error('useMultiContentContext must be used within a '); + } + return ctx as MultiContent; +} + +/** + * 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(contentId: keyof T) { + const { updateContentAt, saveSnapshotAndRemoveContent, getData } = useMultiContentContext(); + + 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, + }; +} diff --git a/src/plugins/es_ui_shared/public/forms/multi_content/use_multi_content.ts b/src/plugins/es_ui_shared/public/forms/multi_content/use_multi_content.ts new file mode 100644 index 0000000000000..0a2c7bb651959 --- /dev/null +++ b/src/plugins/es_ui_shared/public/forms/multi_content/use_multi_content.ts @@ -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 { + isValid: boolean | undefined; + validate(): Promise; + getData(): T; +} + +type Contents = { + [K in keyof T]: Content; +}; + +interface Validation { + isValid: boolean | undefined; + contents: { + [K in keyof T]: boolean | undefined; + }; +} + +export interface HookProps { + defaultValue?: T; + onChange?: (output: Content) => void; +} + +export interface MultiContent { + updateContentAt: (id: keyof T, content: Content) => void; + saveSnapshotAndRemoveContent: (id: keyof T) => void; + getData: () => T; + validate: () => Promise; + validation: Validation; +} + +export function useMultiContent({ + defaultValue, + onChange, +}: HookProps): MultiContent { + /** + * 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>({ + isValid: true, + contents: {}, + } as Validation); + + /** + * The updated data where a content current data is merged when it unmounts + */ + const [stateData, setStateData] = useState(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>({} as Contents); + + 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 = {}; + + 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, + }; +} diff --git a/src/plugins/es_ui_shared/public/forms/multi_content/with_multi_content.tsx b/src/plugins/es_ui_shared/public/forms/multi_content/with_multi_content.tsx new file mode 100644 index 0000000000000..e69ce4c6fa145 --- /dev/null +++ b/src/plugins/es_ui_shared/public/forms/multi_content/with_multi_content.tsx @@ -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

>) { + return function (props: P & HookProps) { + const { defaultValue, onChange, ...rest } = props; + return ( + defaultValue={defaultValue} onChange={onChange}> + + + ); + }; +} diff --git a/src/plugins/es_ui_shared/public/index.ts b/src/plugins/es_ui_shared/public/index.ts index 4ab791289dd88..28baa3d8372f0 100644 --- a/src/plugins/es_ui_shared/public/index.ts +++ b/src/plugins/es_ui_shared/public/index.ts @@ -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 { diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/configuration_form/configuration_form.tsx b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/configuration_form/configuration_form.tsx index 19eec0f0a9f9d..bc4fa67e4658f 100644 --- a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/configuration_form/configuration_form.tsx +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/configuration_form/configuration_form.tsx @@ -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) { diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/create_field/create_field.tsx b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/create_field/create_field.tsx index 230e6615bc4a4..62eb920f8865d 100644 --- a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/create_field/create_field.tsx +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/create_field/create_field.tsx @@ -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' }); diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/edit_field/edit_field_container.tsx b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/edit_field/edit_field_container.tsx index 534c891a6f394..d543e49d23be9 100644 --- a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/edit_field/edit_field_container.tsx +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/edit_field/edit_field_container.tsx @@ -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' }); diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields_tree_editor.tsx b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields_tree_editor.tsx index cb0016e967c42..9d9df38ef4e25 100644 --- a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields_tree_editor.tsx +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields_tree_editor.tsx @@ -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; diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/templates_form/templates_form.tsx b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/templates_form/templates_form.tsx index e6b7eeb12b4c8..80937e7da1192 100644 --- a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/templates_form/templates_form.tsx +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/templates_form/templates_form.tsx @@ -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) { diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/index.ts b/x-pack/plugins/index_management/public/application/components/mappings_editor/index.ts index a796a2d1124b4..2b8f50de7642f 100644 --- a/x-pack/plugins/index_management/public/application/components/mappings_editor/index.ts +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/index.ts @@ -13,3 +13,4 @@ export * from './components/load_mappings'; export { OnUpdateHandler, Types } from './mappings_state'; export { doMappingsHaveType } from './lib'; +export { IndexSettings } from './types'; diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/index_settings_context.tsx b/x-pack/plugins/index_management/public/application/components/mappings_editor/index_settings_context.tsx index 04e0980513b6a..9e3637f970293 100644 --- a/x-pack/plugins/index_management/public/application/components/mappings_editor/index_settings_context.tsx +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/index_settings_context.tsx @@ -13,7 +13,7 @@ interface Props { children: React.ReactNode; } -export const IndexSettingsProvider = ({ indexSettings, children }: Props) => ( +export const IndexSettingsProvider = ({ indexSettings = {}, children }: Props) => ( {children} ); diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/lib/utils.test.ts b/x-pack/plugins/index_management/public/application/components/mappings_editor/lib/utils.test.ts index 4b610ff0b401d..bc495b05e07b7 100644 --- a/x-pack/plugins/index_management/public/application/components/mappings_editor/lib/utils.test.ts +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/lib/utils.test.ts @@ -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(); diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/lib/utils.ts b/x-pack/plugins/index_management/public/application/components/mappings_editor/lib/utils.ts index 55608b5fa74e4..e7c27cb413b53 100644 --- a/x-pack/plugins/index_management/public/application/components/mappings_editor/lib/utils.ts +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/lib/utils.ts @@ -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'; @@ -516,24 +515,6 @@ export const shouldDeleteChildFieldsAfterTypeChange = ( export const canUseMappingsEditor = (maxNestedDepth: number) => maxNestedDepth < MAX_DEPTH_DEFAULT_EDITOR; -const stateWithValidity: Array = ['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: diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/mappings_state.tsx b/x-pack/plugins/index_management/public/application/components/mappings_editor/mappings_state.tsx index 35e50239fb3b7..5b899a341652d 100644 --- a/x-pack/plugins/index_management/public/application/components/mappings_editor/mappings_state.tsx +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/mappings_state.tsx @@ -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 | { [key: string]: Mappings }; + getData: () => Mappings | { [key: string]: Mappings }; validate: () => Promise; } @@ -58,7 +57,7 @@ export const MappingsState = React.memo(({ children, onChange, value, mappingsTy const parsedFieldsDefaultValue = useMemo(() => normalize(value.fields), [value.fields]); const initialState: State = { - isValid: undefined, + isValid: true, configuration: { defaultValue: value.configuration, data: { @@ -77,7 +76,7 @@ export const MappingsState = React.memo(({ children, onChange, value, mappingsTy }, fields: parsedFieldsDefaultValue, documentFields: { - status: 'idle', + status: parsedFieldsDefaultValue.rootLevelFields.length === 0 ? 'creatingField' : 'idle', editor: 'default', }, fieldsJsonEditor: { @@ -106,32 +105,15 @@ export const MappingsState = React.memo(({ children, onChange, value, mappingsTy 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(); const mappings = { ...stripUndefinedValues({ @@ -170,9 +152,11 @@ export const MappingsState = React.memo(({ children, onChange, value, mappingsTy 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, }); diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/reducer.ts b/x-pack/plugins/index_management/public/application/components/mappings_editor/reducer.ts index e0311fc86a3b0..27f8b12493008 100644 --- a/x-pack/plugins/index_management/public/application/components/mappings_editor/reducer.ts +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/reducer.ts @@ -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.`); } diff --git a/x-pack/plugins/index_management/public/application/components/template_form/steps/index.ts b/x-pack/plugins/index_management/public/application/components/template_form/steps/index.ts index b78087ece94b9..95d1222ad2cc9 100644 --- a/x-pack/plugins/index_management/public/application/components/template_form/steps/index.ts +++ b/x-pack/plugins/index_management/public/application/components/template_form/steps/index.ts @@ -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'; diff --git a/x-pack/plugins/index_management/public/application/components/template_form/steps/step_aliases.tsx b/x-pack/plugins/index_management/public/application/components/template_form/steps/step_aliases.tsx index 50a32787c7a04..e18846a69b847 100644 --- a/x-pack/plugins/index_management/public/application/components/template_form/steps/step_aliases.tsx +++ b/x-pack/plugins/index_management/public/application/components/template_form/steps/step_aliases.tsx @@ -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 = ({ - 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 ( -

- - - -

- -

-
+export const StepAliases: React.FunctionComponent = React.memo( + ({ defaultValue, onChange }) => { + const { jsonContent, setJsonContent, error } = useJsonStep({ + defaultValue, + onChange, + }); - + return ( +
+ + + +

+ +

+
- -

+ + + +

+ +

+
+
+ + + -

- -
+ + +
+ + - - + {/* Aliases code editor */} + - - - - - - - {/* Aliases code editor */} - - } - helpText={ - - {JSON.stringify({ - my_alias: {}, - })} - - ), + } + helpText={ + + {JSON.stringify({ + my_alias: {}, + })} + + ), + }} + /> + } + isInvalid={Boolean(error)} + error={error} + fullWidth + > + - } - isInvalid={Boolean(error)} - error={error} - fullWidth - > - { - setContent(updated); - }} - data-test-subj="aliasesEditor" - /> - -
- ); -}; + +
+ ); + } +); diff --git a/x-pack/plugins/index_management/public/application/components/template_form/steps/step_aliases_container.tsx b/x-pack/plugins/index_management/public/application/components/template_form/steps/step_aliases_container.tsx new file mode 100644 index 0000000000000..634887436f816 --- /dev/null +++ b/x-pack/plugins/index_management/public/application/components/template_form/steps/step_aliases_container.tsx @@ -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('aliases'); + + return ; +}; diff --git a/x-pack/plugins/index_management/public/application/components/template_form/steps/step_logistics.tsx b/x-pack/plugins/index_management/public/application/components/template_form/steps/step_logistics.tsx index 2f6e055b5d0c6..d011b4b06546a 100644 --- a/x-pack/plugins/index_management/public/application/components/template_form/steps/step_logistics.tsx +++ b/x-pack/plugins/index_management/public/application/components/template_form/steps/step_logistics.tsx @@ -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 = ({ - 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 = 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 ( -
- - - -

+ return ( + + + + +

+ +

+
+
+ + + -

-
-
- - - - - - -
- - {/* Name */} - - - - {/* Index patterns */} - - - - {/* Order */} - - - - {/* Version */} - - - - - ); -}; + + + + + {/* Name */} + + + + {/* Index patterns */} + + + + {/* Order */} + + + + {/* Version */} + + + + + ); + } +); diff --git a/x-pack/plugins/index_management/public/application/components/template_form/steps/step_logistics_container.tsx b/x-pack/plugins/index_management/public/application/components/template_form/steps/step_logistics_container.tsx new file mode 100644 index 0000000000000..867ecff799858 --- /dev/null +++ b/x-pack/plugins/index_management/public/application/components/template_form/steps/step_logistics_container.tsx @@ -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('logistics'); + + return ( + + ); +}; diff --git a/x-pack/plugins/index_management/public/application/components/template_form/steps/step_mappings.tsx b/x-pack/plugins/index_management/public/application/components/template_form/steps/step_mappings.tsx index d74dd435ecdae..800cb519a9393 100644 --- a/x-pack/plugins/index_management/public/application/components/template_form/steps/step_mappings.tsx +++ b/x-pack/plugins/index_management/public/application/components/template_form/steps/step_mappings.tsx @@ -14,99 +14,103 @@ import { EuiSpacer, EuiText, } from '@elastic/eui'; -import { documentationService } from '../../../services/documentation'; -import { StepProps, DataGetterFunc } from '../types'; -import { MappingsEditor, OnUpdateHandler, LoadMappingsFromJsonButton } from '../../mappings_editor'; - -export const StepMappings: React.FunctionComponent = ({ - template, - setDataGetter, - onStepValidityChange, -}) => { - const [mappings, setMappings] = useState(template?.template.mappings); - - const onMappingsEditorUpdate = useCallback( - ({ isValid, getData, validate }) => { - onStepValidityChange(isValid); - const dataGetterFunc: DataGetterFunc = async () => { - const isMappingsValid = isValid === undefined ? await validate() : isValid; - const data = getData(isMappingsValid); - return { - isValid: isMappingsValid, - data: { mappings: data }, - path: 'template', - }; - }; +import { Forms } from '../../../../shared_imports'; +import { documentationService } from '../../../services/documentation'; +import { + MappingsEditor, + OnUpdateHandler, + LoadMappingsFromJsonButton, + IndexSettings, +} from '../../mappings_editor'; - setDataGetter(dataGetterFunc); - }, - [setDataGetter, onStepValidityChange] - ); +interface Props { + defaultValue: { [key: string]: any }; + onChange: (content: Forms.Content) => void; + indexSettings?: IndexSettings; +} - const onJsonLoaded = (json: { [key: string]: any }): void => { - setMappings(json); - }; +export const StepMappings: React.FunctionComponent = React.memo( + ({ defaultValue, onChange, indexSettings }) => { + const [mappings, setMappings] = useState(defaultValue); - return ( -
- - - -

- -

-
+ const onMappingsEditorUpdate = useCallback( + ({ isValid, getData, validate }) => { + onChange({ + isValid, + async validate() { + return isValid === undefined ? await validate() : isValid; + }, + getData, + }); + }, + [onChange] + ); - + const onJsonLoaded = (json: { [key: string]: any }): void => { + setMappings(json); + }; - -

- -

-
-
+ return ( +
+ + + +

+ +

+
- - - - - + - - + +

- - - - - +

+
+
+ + + + + + + + + + + + + + +
- + - {/* Mappings code editor */} - + {/* Mappings editor */} + - -
- ); -}; + +
+ ); + } +); diff --git a/x-pack/plugins/index_management/public/application/components/template_form/steps/step_mappings_container.tsx b/x-pack/plugins/index_management/public/application/components/template_form/steps/step_mappings_container.tsx new file mode 100644 index 0000000000000..545aec9851592 --- /dev/null +++ b/x-pack/plugins/index_management/public/application/components/template_form/steps/step_mappings_container.tsx @@ -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('mappings'); + + return ( + + ); +}; diff --git a/x-pack/plugins/index_management/public/application/components/template_form/steps/step_review.tsx b/x-pack/plugins/index_management/public/application/components/template_form/steps/step_review.tsx index f9de6dfbfda38..ab49736d8c0bb 100644 --- a/x-pack/plugins/index_management/public/application/components/template_form/steps/step_review.tsx +++ b/x-pack/plugins/index_management/public/application/components/template_form/steps/step_review.tsx @@ -28,7 +28,7 @@ import { } from '../../../../../common/lib/template_serialization'; import { TemplateDeserialized, getTemplateParameter } from '../../../../../common'; import { doMappingsHaveType } from '../../mappings_editor/lib'; -import { StepProps } from '../types'; +import { WizardSection } from '../template_form'; const { stripEmptyFields } = serializers; @@ -55,224 +55,231 @@ const getDescriptionText = (data: any) => { ); }; -export const StepReview: React.FunctionComponent = ({ 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 = 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 = () => ( -
- + const hasWildCardIndexPattern = Boolean(indexPatterns!.find((pattern) => pattern === '*')); - - - - - - - - {numIndexPatterns > 1 ? ( - -
    - {indexPatterns!.map((indexName: string, i: number) => { - return ( -
  • - - {indexName} - -
  • - ); - })} -
-
- ) : ( - indexPatterns!.toString() - )} -
+ const SummaryTab = () => ( +
+ - - - - - {order ? order : } - + + + + + + + + {numIndexPatterns > 1 ? ( + +
    + {indexPatterns!.map((indexName: string, i: number) => { + return ( +
  • + + {indexName} + +
  • + ); + })} +
+
+ ) : ( + indexPatterns!.toString() + )} +
- - - - - {version ? version : } - -
-
+ + + + + {order ? order : } + - - - - - - - {getDescriptionText(serializedSettings)} - - - - - - {getDescriptionText(serializedMappings)} - - + + + + + {version ? version : } + + + + + + + + + + + {getDescriptionText(serializedSettings)} + + + + + + {getDescriptionText(serializedMappings)} + + + + + + {getDescriptionText(serializedAliases)} + + + +
+
+ ); + + const RequestTab = () => { + const includeTypeName = doMappingsHaveType(template!.template.mappings); + const endpoint = `PUT _template/${name || ''}${ + includeTypeName ? '?include_type_name' : '' + }`; + 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 ( +
+ + + +

- - - {getDescriptionText(serializedAliases)} - - - - -

- ); +

+ - const RequestTab = () => { - const includeTypeName = doMappingsHaveType(template!.template.mappings); - const endpoint = `PUT _template/${name || ''}${ - includeTypeName ? '?include_type_name' : '' - }`; - 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; + + {request} + +
+ ); + }; return ( -
- - - -

+

+ +

-

- +

+
- - - - {request} - -
- ); - }; + - return ( -
- -

- -

-
- - - - {hasWildCardIndexPattern ? ( - - - } - color="warning" - iconType="help" - data-test-subj="indexPatternsWarning" - > -

- {' '} - {/* Edit link navigates back to step 1 (logistics) */} - + {hasWildCardIndexPattern ? ( + + - -

-
- -
- ) : null} + } + color="warning" + iconType="help" + data-test-subj="indexPatternsWarning" + > +

+ {' '} + {/* Edit link navigates back to step 1 (logistics) */} + + + +

+ + + + ) : null} - , - }, - { - id: 'request', - name: i18n.translate('xpack.idxMgmt.templateForm.stepReview.requestTabTitle', { - defaultMessage: 'Request', - }), - content: , - }, - ]} - /> -
- ); -}; + , + }, + { + id: 'request', + name: i18n.translate('xpack.idxMgmt.templateForm.stepReview.requestTabTitle', { + defaultMessage: 'Request', + }), + content: , + }, + ]} + /> +
+ ); + } +); diff --git a/x-pack/plugins/index_management/public/application/components/template_form/steps/step_review_container.tsx b/x-pack/plugins/index_management/public/application/components/template_form/steps/step_review_container.tsx new file mode 100644 index 0000000000000..cafa8660b1150 --- /dev/null +++ b/x-pack/plugins/index_management/public/application/components/template_form/steps/step_review_container.tsx @@ -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(); + const { getData } = Forms.useMultiContentContext(); + + const wizardContent = getData(); + // Build the final template object, providing the wizard content data + const template = getTemplateData(wizardContent); + + return ; +}); diff --git a/x-pack/plugins/index_management/public/application/components/template_form/steps/step_settings.tsx b/x-pack/plugins/index_management/public/application/components/template_form/steps/step_settings.tsx index 7c1ee6388a618..4325852d68aaa 100644 --- a/x-pack/plugins/index_management/public/application/components/template_form/steps/step_settings.tsx +++ b/x-pack/plugins/index_management/public/application/components/template_form/steps/step_settings.tsx @@ -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 = ({ - 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 ( -
- - - -

- -

-
+export const StepSettings: React.FunctionComponent = React.memo( + ({ defaultValue, onChange }) => { + const { jsonContent, setJsonContent, error } = useJsonStep({ + defaultValue, + onChange, + }); - + return ( +
+ + + +

+ +

+
- -

+ + + +

+ +

+
+
+ + + -

- -
+ + +
+ + - - + {/* Settings code editor */} + - - - - - - - {/* Settings code editor */} - - } - helpText={ - {JSON.stringify({ number_of_replicas: 1 })}, + } + helpText={ + {JSON.stringify({ number_of_replicas: 1 })}, + }} + /> + } + isInvalid={Boolean(error)} + error={error} + fullWidth + > + - } - isInvalid={Boolean(error)} - error={error} - fullWidth - > - setContent(updated)} - data-test-subj="settingsEditor" - /> - -
- ); -}; + +
+ ); + } +); diff --git a/x-pack/plugins/index_management/public/application/components/template_form/steps/step_settings_container.tsx b/x-pack/plugins/index_management/public/application/components/template_form/steps/step_settings_container.tsx new file mode 100644 index 0000000000000..4d7de644a1442 --- /dev/null +++ b/x-pack/plugins/index_management/public/application/components/template_form/steps/step_settings_container.tsx @@ -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('settings'); + + return ; +}); diff --git a/x-pack/plugins/index_management/public/application/components/template_form/steps/use_json_step.ts b/x-pack/plugins/index_management/public/application/components/template_form/steps/use_json_step.ts index 25dbe784db3a1..4c1b36e3abba5 100644 --- a/x-pack/plugins/index_management/public/application/components/template_form/steps/use_json_step.ts +++ b/x-pack/plugins/index_management/public/application/components/template_form/steps/use_json_step.ts @@ -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(stringifyJson(defaultValue)); +export const useJsonStep = ({ defaultValue, onChange }: Parameters) => { + const [jsonContent, setJsonContent] = useState(stringifyJson(defaultValue ?? {})); const [error, setError] = useState(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]); + }, [jsonContent]); - const dataGetter = useCallback(() => { + useEffect(() => { 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 }; + 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; + }; - return Promise.resolve({ isValid, data, path: 'template' }); - }, [content, validateContent, prop]); + const content = { + isValid, + validate: async () => isValid, + getData, + }; - useEffect(() => { - const isValid = validateContent(); - onStepValidityChange(isValid); - setDataGetter(dataGetter); - }, [content, dataGetter, onStepValidityChange, setDataGetter, validateContent]); + onChange(content); + }, [jsonContent, onChange, validateContent]); return { - content, - setContent, + jsonContent, + setJsonContent, error, }; }; diff --git a/x-pack/plugins/index_management/public/application/components/template_form/template_form.tsx b/x-pack/plugins/index_management/public/application/components/template_form/template_form.tsx index 52e26e6d3e895..9e6d49faac563 100644 --- a/x-pack/plugins/index_management/public/application/components/template_form/template_form.tsx +++ b/x-pack/plugins/index_management/public/application/components/template_form/template_form.tsx @@ -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; + 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 } = { - 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 = ({ +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(1); - const [validation, setValidation] = useState({ - 1: defaultValidation, - 2: defaultValidation, - 3: defaultValidation, - 4: defaultValidation, - 5: defaultValidation, - }); - - const template = useRef(defaultValue); - const stepsDataGetters = useRef>({}); - - const lastStep = Object.keys(stepComponentMap).length; - const CurrentStepComponent = stepComponentMap[currentStep]; - const isStepValid = validation[currentStep].isValid; - - const setStepDataGetter = useCallback( - (stepDataGetter: DataGetterFunc) => { - stepsDataGetters.current[currentStep] = stepDataGetter; - }, - [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); + onSave, +}: Props) => { + const { + template: { settings, mappings, aliases }, + _kbnMeta, + ...logistics + } = defaultValue; + + const wizardDefaultValue: WizardContent = { + logistics, + settings, + mappings, + aliases, }; - const saveButtonLabel = isEditing ? ( - - ) : ( - - ); - - return ( - - + ) : ( + + ), + }; - - - {saveError ? ( - - - } - error={saveError} - data-test-subj="saveTemplateError" + const apiError = saveError ? ( + <> + - - - ) : null} - - - - + } + error={saveError} + data-test-subj="saveTemplateError" + /> + + + ) : null; + + const buildTemplateObject = (initialTemplate: TemplateDeserialized) => ( + wizardData: WizardContent + ): TemplateDeserialized => ({ + ...initialTemplate, + ...wizardData.logistics, + template: { + settings: wizardData.settings, + mappings: wizardData.mappings, + aliases: wizardData.aliases, + }, + }); - - - - {currentStep > 1 ? ( - - - - - - ) : null} + const onSaveTemplate = useCallback( + async (wizardData: WizardContent) => { + const template = buildTemplateObject(defaultValue)(wizardData); - {currentStep < lastStep ? ( - - - - - - ) : null} + // 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 + ); - {currentStep === lastStep ? ( - - - {isSaving ? ( - - ) : ( - saveButtonLabel - )} - - - ) : null} - - - - + clearSaveError(); + }, + [defaultValue, onSave, clearSaveError] + ); - - + return ( + + defaultValue={wizardDefaultValue} + onSave={onSaveTemplate} + isEditing={isEditing} + isSaving={isSaving} + apiError={apiError} + texts={i18nTexts} + > + + + + + + + + + + + + + + + + + + + + ); }; diff --git a/x-pack/plugins/index_management/public/application/components/template_form/template_steps.tsx b/x-pack/plugins/index_management/public/application/components/template_form/template_steps.tsx deleted file mode 100644 index 7a31c74c1a9c2..0000000000000 --- a/x-pack/plugins/index_management/public/application/components/template_form/template_steps.tsx +++ /dev/null @@ -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 = ({ - 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 ; -}; diff --git a/x-pack/plugins/index_management/public/application/components/template_form/types.ts b/x-pack/plugins/index_management/public/application/components/template_form/types.ts deleted file mode 100644 index 5db53e91ed261..0000000000000 --- a/x-pack/plugins/index_management/public/application/components/template_form/types.ts +++ /dev/null @@ -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; -}>; diff --git a/x-pack/plugins/index_management/public/shared_imports.ts b/x-pack/plugins/index_management/public/shared_imports.ts index afd5a5cf650e1..69cd07ba6dba0 100644 --- a/x-pack/plugins/index_management/public/shared_imports.ts +++ b/x-pack/plugins/index_management/public/shared_imports.ts @@ -10,6 +10,7 @@ export { UseRequestConfig, sendRequest, useRequest, + Forms, } from '../../../../src/plugins/es_ui_shared/public/'; export { diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index aa17f1844e702..8cba6432d0b9c 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -7086,12 +7086,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": "インデックステンプレートドキュメント", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 91796243b9373..df69b6c91be7f 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -7090,12 +7090,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": "索引模板文档",