From 73c1bc083798eb9e6b2c43051d73d56003aee33b Mon Sep 17 00:00:00 2001 From: Walter Rafelsberger Date: Wed, 17 Jan 2024 14:42:38 +0100 Subject: [PATCH] [ML] Transform: Use redux toolkit for state management for edit transform flyout (#173861) ## Summary Part of #151664. This replaces the state management of the edit transform flyout with Redux Toolkit (which is already part of Kibana's `package.json`). Previously, we avoided redux because of the additional boilerplate. However, Redux Toolkit provides utilities (e.g. `createSlice`, immutable state updates via `immer`) that significantly reduce the necessary boilerplate. For example, there is no longer a need to write classic action creators or even a reducer. In fact, this PR gets rid of action reactors and the reducer that were mimicking classic redux behaviour. If you know a bit of old plain boilerplaty redux, have a look here how it looks with Redux Toolkit: https://redux-toolkit.js.org/tutorials/quick-start (look out for the `counterSlice` part, that explain the main difference to writing old school action creators and reducers). So instead of a full reducer and corresponding action definitions, we can now write more simple callbacks that will end up as reducer actions being automatically set up via `createSlice`: ```ts const setApiError = (state: State, action: PayloadAction) => { state.apiErrorMessage = action.payload; }; ``` Note that under the hood redux toolkit uses `immer` which allows us to write the above shorter notation, it let's us treat immutable state updates as if we're mutating `state`. Otherwise we'd have to write the following to be returned from the action: ```ts ({ ...state, apiErrorMessage: action.payload }) ``` This becomes even more useful for otherwise painful nested state updates. Here's a nice reference on how to do various types of state updates with `immer`: https://immerjs.github.io/immer/update-patterns/ On the other hand, to consume data from the redux store, we use so-called selectors. Under the hood they are optimized to avoid unnecessary rerenders or even render loops, something we especially had to work around in the state management of the transform creation wizard with custom state comparison. Simple selector setup and usage would look like this: ```ts // state.ts export const selectApiErrorMessage = (s: State) => s.apiErrorMessage; export const useApiErrorMessage = () => useSelector(selectApiErrorMessage); // component.tsx export const ApiErrorCallout: FC = () => { const apiErrorMessage = useApiErrorMessage(); return

{apiErrorMessage}

; } ``` It's certainly possible and you might be tempted to write these simple selectors inline like `useSelector((s: State) => s.apiErrorMessage)`. However, note that you'd then still have to pass around the `State` type. And you might quickly lose track of which state attributes you use across your components. Keeping the selector code close to where you manage state will help with maintainability and testing. Be aware that as soon as you require local state in components derived from more than one redux store attribute or including more complex transformations, you might again run into unnecessary rerenders. To work around this, redux toolkit includes the `reselect` library's `createSelector`. This will allow you to write selectors with proper memoization. They work a bit like the map-reduce pattern: As the `map` part you'll select multiple attributes from your state and the `reduce` part will return data derived from these state attributes. Think of it as a map-reduce-like subscription to your store. For example, prior to this PR, we set the `isFormValid` attribute actively as part of the update actions in the form's state. This new version no longer has `isFormValid` as a state attribute, instead it is derived from the form's field statuses as part of a selector and we "subscribe" to it using `useSelector()`. ```ts // state.ts const isFormValid = (formFields: FormFieldsState) => Object.values(formFields).every((d) => d.errorMessages.length === 0); const selectIsFormValid = createSelector((state: State) => state.formFields, isFormValid); export const useIsFormValid = () => useSelector(selectIsFormValid); // component.tsx export const UpdateTransform: FC = () => { const isFormValid = useIsFormValid(); .... } ``` In the above code, the `isFormValid()` function in `state.ts` is the same code we used previously to actively verify on state actions. However, this approach was more risky because we could miss adding that check on a new state action. Instead, `selectIsFormValid` sets us up to switch to the more subscription like pattern. For `createSelector`, the first argument `state: State) => state.formFields` just picks `formFields` (= map step), the second argument passes `isFormValid` to do the actual validation (= reduce step). Finally, for more convenience we wrap everything in a custom hook `useIsFormValid`. This way the consuming component ends up really simple, all with proper memoization in place. Memoization gets a bit more tricky if we want to combine selectors with information we have only available as props or via react context. For example, the wrapping component of the flyout to edit transforms provides the original transform `config` and an optional `dataViewId`. If we want to find out if a user changed the form, we need to compare the form state to the original transform config. The following code sets us up to achieve that with memoization: ```ts // state.ts const createSelectIsFormTouched = (originalConfig: TransformConfigUnion) => createSelector( (state: State) => state.formFields, (state: State) => state.formSections, (formFields, formSections) => isFormTouched(originalConfig, formFields, formSections) ); export const useIsFormTouched = () => { const { config } = useEditTransformFlyoutContext(); const selectIsFormTouched = useMemo(() => createSelectIsFormTouched(config), [config]); return useSelector(selectIsFormTouched); }; // component.tsx export const UpdateTransform: FC = () => { const isFormTouched = useIsFormTouched(); .... } ``` `createSelectIsFormTouched` is a factory that takes the original transform config and returns a selector that uses it to verify if the form state changed from the original config (That's what's called currying: A function returning another function, where the args of the first function get set in stone and are available to the scope of the second function). To properly memoize this, the custom hook `useIsFormTouched()` puts this factory inside a `useMemo` so the selector would only change once the original config changes. Then that memoized selector gets passed on to `useSelector`. Again, the code in the component itself ends up being really simple. For more examples on how to write proper memoized selectors, have a look here: https://github.com/amsterdamharu/selectors ### Checklist - [x] [Unit or functional tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html) were updated or added to match the most common scenarios - [x] This was checked for breaking API changes and was [labeled appropriately](https://www.elastic.co/guide/en/kibana/master/contributing.html#kibana-release-notes-process) --- .../validators/frequency_validator.test.ts | 3 - .../edit_transform_api_error_callout.tsx | 35 ++ .../components}/edit_transform_flyout.tsx | 9 +- .../edit_transform_flyout_callout.tsx | 2 +- .../edit_transform_flyout_form.tsx | 0 .../edit_transform_flyout_form_text_area.tsx | 17 +- .../edit_transform_flyout_form_text_input.tsx | 17 +- .../edit_transform_ingest_pipeline.tsx | 12 +- .../edit_transform_retention_policy.tsx | 48 +- .../edit_transform_update_button.tsx | 27 +- .../index.ts | 2 +- .../__mocks__/transform_config.ts | 38 ++ .../state_management/actions.ts | 79 +++ ...ly_form_state_to_transform_config.test.ts} | 142 ++--- .../apply_form_state_to_transform_config.ts | 37 ++ .../edit_transform_flyout_state.test.tsx | 75 +++ .../edit_transform_flyout_state.tsx | 83 +++ .../state_management/form_field.ts | 70 +++ .../state_management/form_section.ts | 45 ++ .../state_management/get_default_state.ts | 109 ++++ .../state_management/get_update_value.ts | 80 +++ .../selectors/api_error_message.ts | 15 + .../state_management/selectors/form_field.ts | 22 + .../selectors/form_sections.ts | 15 + .../selectors/is_form_touched.ts | 49 ++ .../selectors/is_form_valid.ts | 20 + .../selectors/retention_policy_field.ts | 15 + .../selectors/updated_transform_config.ts | 29 + .../state_management/validators.ts | 25 + .../state_management/value_parsers.ts | 19 + .../utils}/capitalize_first_letter.ts | 0 .../edit_transform_api_error_callout.tsx | 40 -- .../use_edit_transform_flyout.tsx | 514 ------------------ .../components/transform_list/use_actions.tsx | 2 +- 34 files changed, 984 insertions(+), 711 deletions(-) create mode 100644 x-pack/plugins/transform/public/app/sections/edit_transform/components/edit_transform_api_error_callout.tsx rename x-pack/plugins/transform/public/app/sections/{transform_management/components/edit_transform_flyout => edit_transform/components}/edit_transform_flyout.tsx (87%) rename x-pack/plugins/transform/public/app/sections/{transform_management/components/edit_transform_flyout => edit_transform/components}/edit_transform_flyout_callout.tsx (94%) rename x-pack/plugins/transform/public/app/sections/{transform_management/components/edit_transform_flyout => edit_transform/components}/edit_transform_flyout_form.tsx (100%) rename x-pack/plugins/transform/public/app/sections/{transform_management/components/edit_transform_flyout => edit_transform/components}/edit_transform_flyout_form_text_area.tsx (71%) rename x-pack/plugins/transform/public/app/sections/{transform_management/components/edit_transform_flyout => edit_transform/components}/edit_transform_flyout_form_text_input.tsx (72%) rename x-pack/plugins/transform/public/app/sections/{transform_management/components/edit_transform_flyout => edit_transform/components}/edit_transform_ingest_pipeline.tsx (85%) rename x-pack/plugins/transform/public/app/sections/{transform_management/components/edit_transform_flyout => edit_transform/components}/edit_transform_retention_policy.tsx (82%) rename x-pack/plugins/transform/public/app/sections/{transform_management/components/edit_transform_flyout => edit_transform/components}/edit_transform_update_button.tsx (53%) rename x-pack/plugins/transform/public/app/sections/{transform_management/components/edit_transform_flyout => edit_transform}/index.ts (77%) create mode 100644 x-pack/plugins/transform/public/app/sections/edit_transform/state_management/__mocks__/transform_config.ts create mode 100644 x-pack/plugins/transform/public/app/sections/edit_transform/state_management/actions.ts rename x-pack/plugins/transform/public/app/sections/{transform_management/components/edit_transform_flyout/use_edit_transform_flyout.test.ts => edit_transform/state_management/apply_form_state_to_transform_config.test.ts} (61%) create mode 100644 x-pack/plugins/transform/public/app/sections/edit_transform/state_management/apply_form_state_to_transform_config.ts create mode 100644 x-pack/plugins/transform/public/app/sections/edit_transform/state_management/edit_transform_flyout_state.test.tsx create mode 100644 x-pack/plugins/transform/public/app/sections/edit_transform/state_management/edit_transform_flyout_state.tsx create mode 100644 x-pack/plugins/transform/public/app/sections/edit_transform/state_management/form_field.ts create mode 100644 x-pack/plugins/transform/public/app/sections/edit_transform/state_management/form_section.ts create mode 100644 x-pack/plugins/transform/public/app/sections/edit_transform/state_management/get_default_state.ts create mode 100644 x-pack/plugins/transform/public/app/sections/edit_transform/state_management/get_update_value.ts create mode 100644 x-pack/plugins/transform/public/app/sections/edit_transform/state_management/selectors/api_error_message.ts create mode 100644 x-pack/plugins/transform/public/app/sections/edit_transform/state_management/selectors/form_field.ts create mode 100644 x-pack/plugins/transform/public/app/sections/edit_transform/state_management/selectors/form_sections.ts create mode 100644 x-pack/plugins/transform/public/app/sections/edit_transform/state_management/selectors/is_form_touched.ts create mode 100644 x-pack/plugins/transform/public/app/sections/edit_transform/state_management/selectors/is_form_valid.ts create mode 100644 x-pack/plugins/transform/public/app/sections/edit_transform/state_management/selectors/retention_policy_field.ts create mode 100644 x-pack/plugins/transform/public/app/sections/edit_transform/state_management/selectors/updated_transform_config.ts create mode 100644 x-pack/plugins/transform/public/app/sections/edit_transform/state_management/validators.ts create mode 100644 x-pack/plugins/transform/public/app/sections/edit_transform/state_management/value_parsers.ts rename x-pack/plugins/transform/public/app/sections/{transform_management/components/edit_transform_flyout => edit_transform/utils}/capitalize_first_letter.ts (100%) delete mode 100644 x-pack/plugins/transform/public/app/sections/transform_management/components/edit_transform_flyout/edit_transform_api_error_callout.tsx delete mode 100644 x-pack/plugins/transform/public/app/sections/transform_management/components/edit_transform_flyout/use_edit_transform_flyout.tsx diff --git a/x-pack/plugins/transform/public/app/common/validators/frequency_validator.test.ts b/x-pack/plugins/transform/public/app/common/validators/frequency_validator.test.ts index 1ebdd3d41cd57..ff75231e82ade 100644 --- a/x-pack/plugins/transform/public/app/common/validators/frequency_validator.test.ts +++ b/x-pack/plugins/transform/public/app/common/validators/frequency_validator.test.ts @@ -8,9 +8,6 @@ import { frequencyValidator } from './frequency_validator'; describe('Transform: frequencyValidator()', () => { - // frequencyValidator() returns an array of error messages so - // an array with a length of 0 means a successful validation. - it('should fail when the input is not an integer and valid time unit.', () => { expect(frequencyValidator('0')).toEqual(['The frequency value is not valid.']); expect(frequencyValidator('0.1s')).toEqual(['The frequency value is not valid.']); diff --git a/x-pack/plugins/transform/public/app/sections/edit_transform/components/edit_transform_api_error_callout.tsx b/x-pack/plugins/transform/public/app/sections/edit_transform/components/edit_transform_api_error_callout.tsx new file mode 100644 index 0000000000000..4227a05d78d02 --- /dev/null +++ b/x-pack/plugins/transform/public/app/sections/edit_transform/components/edit_transform_api_error_callout.tsx @@ -0,0 +1,35 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { type FC } from 'react'; + +import { EuiCallOut, EuiSpacer } from '@elastic/eui'; + +import { i18n } from '@kbn/i18n'; + +import { useApiErrorMessage } from '../state_management/selectors/api_error_message'; + +export const EditTransformApiErrorCallout: FC = () => { + const apiErrorMessage = useApiErrorMessage(); + + if (apiErrorMessage === undefined) return null; + + return ( + <> + + +

{apiErrorMessage}

+
+ + ); +}; diff --git a/x-pack/plugins/transform/public/app/sections/transform_management/components/edit_transform_flyout/edit_transform_flyout.tsx b/x-pack/plugins/transform/public/app/sections/edit_transform/components/edit_transform_flyout.tsx similarity index 87% rename from x-pack/plugins/transform/public/app/sections/transform_management/components/edit_transform_flyout/edit_transform_flyout.tsx rename to x-pack/plugins/transform/public/app/sections/edit_transform/components/edit_transform_flyout.tsx index c3ff7198a44fa..1369529377a81 100644 --- a/x-pack/plugins/transform/public/app/sections/transform_management/components/edit_transform_flyout/edit_transform_flyout.tsx +++ b/x-pack/plugins/transform/public/app/sections/edit_transform/components/edit_transform_flyout.tsx @@ -20,15 +20,16 @@ import { EuiTitle, } from '@elastic/eui'; -import { isManagedTransform } from '../../../../common/managed_transforms_utils'; +import { isManagedTransform } from '../../../common/managed_transforms_utils'; -import { ManagedTransformsWarningCallout } from '../managed_transforms_callout/managed_transforms_callout'; -import type { EditAction } from '../action_edit'; +import { ManagedTransformsWarningCallout } from '../../transform_management/components/managed_transforms_callout/managed_transforms_callout'; +import type { EditAction } from '../../transform_management/components/action_edit'; + +import { EditTransformFlyoutProvider } from '../state_management/edit_transform_flyout_state'; import { EditTransformApiErrorCallout } from './edit_transform_api_error_callout'; import { EditTransformFlyoutCallout } from './edit_transform_flyout_callout'; import { EditTransformFlyoutForm } from './edit_transform_flyout_form'; -import { EditTransformFlyoutProvider } from './use_edit_transform_flyout'; import { EditTransformUpdateButton } from './edit_transform_update_button'; export const EditTransformFlyout: FC = ({ diff --git a/x-pack/plugins/transform/public/app/sections/transform_management/components/edit_transform_flyout/edit_transform_flyout_callout.tsx b/x-pack/plugins/transform/public/app/sections/edit_transform/components/edit_transform_flyout_callout.tsx similarity index 94% rename from x-pack/plugins/transform/public/app/sections/transform_management/components/edit_transform_flyout/edit_transform_flyout_callout.tsx rename to x-pack/plugins/transform/public/app/sections/edit_transform/components/edit_transform_flyout_callout.tsx index cdaabb3a3b200..ed99bdc911f3e 100644 --- a/x-pack/plugins/transform/public/app/sections/transform_management/components/edit_transform_flyout/edit_transform_flyout_callout.tsx +++ b/x-pack/plugins/transform/public/app/sections/edit_transform/components/edit_transform_flyout_callout.tsx @@ -18,7 +18,7 @@ import { EuiTextColor, } from '@elastic/eui'; -import { useDocumentationLinks } from '../../../../hooks/use_documentation_links'; +import { useDocumentationLinks } from '../../../hooks/use_documentation_links'; export const EditTransformFlyoutCallout: FC = () => { const { esTransformUpdate } = useDocumentationLinks(); diff --git a/x-pack/plugins/transform/public/app/sections/transform_management/components/edit_transform_flyout/edit_transform_flyout_form.tsx b/x-pack/plugins/transform/public/app/sections/edit_transform/components/edit_transform_flyout_form.tsx similarity index 100% rename from x-pack/plugins/transform/public/app/sections/transform_management/components/edit_transform_flyout/edit_transform_flyout_form.tsx rename to x-pack/plugins/transform/public/app/sections/edit_transform/components/edit_transform_flyout_form.tsx diff --git a/x-pack/plugins/transform/public/app/sections/transform_management/components/edit_transform_flyout/edit_transform_flyout_form_text_area.tsx b/x-pack/plugins/transform/public/app/sections/edit_transform/components/edit_transform_flyout_form_text_area.tsx similarity index 71% rename from x-pack/plugins/transform/public/app/sections/transform_management/components/edit_transform_flyout/edit_transform_flyout_form_text_area.tsx rename to x-pack/plugins/transform/public/app/sections/edit_transform/components/edit_transform_flyout_form_text_area.tsx index b4e5470ae7e00..d2ec30de3b104 100644 --- a/x-pack/plugins/transform/public/app/sections/transform_management/components/edit_transform_flyout/edit_transform_flyout_form_text_area.tsx +++ b/x-pack/plugins/transform/public/app/sections/edit_transform/components/edit_transform_flyout_form_text_area.tsx @@ -11,14 +11,13 @@ import { EuiFormRow, EuiTextArea } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; -import { - useEditTransformFlyout, - type EditTransformHookTextInputSelectors, -} from './use_edit_transform_flyout'; -import { capitalizeFirstLetter } from './capitalize_first_letter'; +import { useEditTransformFlyoutActions } from '../state_management/edit_transform_flyout_state'; +import { useFormField } from '../state_management/selectors/form_field'; +import type { FormFields } from '../state_management/form_field'; +import { capitalizeFirstLetter } from '../utils/capitalize_first_letter'; interface EditTransformFlyoutFormTextInputProps { - field: EditTransformHookTextInputSelectors; + field: FormFields; label: string; helpText?: string; placeHolder?: boolean; @@ -30,8 +29,8 @@ export const EditTransformFlyoutFormTextArea: FC { - const { defaultValue, errorMessages, value } = useEditTransformFlyout(field); - const { formField } = useEditTransformFlyout('actions'); + const { defaultValue, errorMessages, value } = useFormField(field); + const { setFormField } = useEditTransformFlyoutActions(); const upperCaseField = capitalizeFirstLetter(field); return ( @@ -53,7 +52,7 @@ export const EditTransformFlyoutFormTextArea: FC 0} value={value} - onChange={(e) => formField({ field, value: e.target.value })} + onChange={(e) => setFormField({ field, value: e.target.value })} aria-label={label} /> diff --git a/x-pack/plugins/transform/public/app/sections/transform_management/components/edit_transform_flyout/edit_transform_flyout_form_text_input.tsx b/x-pack/plugins/transform/public/app/sections/edit_transform/components/edit_transform_flyout_form_text_input.tsx similarity index 72% rename from x-pack/plugins/transform/public/app/sections/transform_management/components/edit_transform_flyout/edit_transform_flyout_form_text_input.tsx rename to x-pack/plugins/transform/public/app/sections/edit_transform/components/edit_transform_flyout_form_text_input.tsx index 9c93d286cb9c4..8548d3c4b9dc5 100644 --- a/x-pack/plugins/transform/public/app/sections/transform_management/components/edit_transform_flyout/edit_transform_flyout_form_text_input.tsx +++ b/x-pack/plugins/transform/public/app/sections/edit_transform/components/edit_transform_flyout_form_text_input.tsx @@ -11,14 +11,13 @@ import { EuiFieldText, EuiFormRow } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; -import { - useEditTransformFlyout, - type EditTransformHookTextInputSelectors, -} from './use_edit_transform_flyout'; -import { capitalizeFirstLetter } from './capitalize_first_letter'; +import { useEditTransformFlyoutActions } from '../state_management/edit_transform_flyout_state'; +import { useFormField } from '../state_management/selectors/form_field'; +import type { FormFields } from '../state_management/form_field'; +import { capitalizeFirstLetter } from '../utils/capitalize_first_letter'; interface EditTransformFlyoutFormTextInputProps { - field: EditTransformHookTextInputSelectors; + field: FormFields; label: string; helpText?: string; placeHolder?: boolean; @@ -30,8 +29,8 @@ export const EditTransformFlyoutFormTextInput: FC { - const { defaultValue, errorMessages, value } = useEditTransformFlyout(field); - const { formField } = useEditTransformFlyout('actions'); + const { defaultValue, errorMessages, value } = useFormField(field); + const { setFormField } = useEditTransformFlyoutActions(); const upperCaseField = capitalizeFirstLetter(field); return ( @@ -53,7 +52,7 @@ export const EditTransformFlyoutFormTextInput: FC 0} value={value} - onChange={(e) => formField({ field, value: e.target.value })} + onChange={(e) => setFormField({ field, value: e.target.value })} aria-label={label} /> diff --git a/x-pack/plugins/transform/public/app/sections/transform_management/components/edit_transform_flyout/edit_transform_ingest_pipeline.tsx b/x-pack/plugins/transform/public/app/sections/edit_transform/components/edit_transform_ingest_pipeline.tsx similarity index 85% rename from x-pack/plugins/transform/public/app/sections/transform_management/components/edit_transform_flyout/edit_transform_ingest_pipeline.tsx rename to x-pack/plugins/transform/public/app/sections/edit_transform/components/edit_transform_ingest_pipeline.tsx index 519bdc94011e1..772f00c7f0a2c 100644 --- a/x-pack/plugins/transform/public/app/sections/transform_management/components/edit_transform_flyout/edit_transform_ingest_pipeline.tsx +++ b/x-pack/plugins/transform/public/app/sections/edit_transform/components/edit_transform_ingest_pipeline.tsx @@ -11,10 +11,12 @@ import { useEuiTheme, EuiComboBox, EuiFormRow, EuiSkeletonRectangle } from '@ela import { i18n } from '@kbn/i18n'; -import { useGetEsIngestPipelines } from '../../../../hooks'; +import { useGetEsIngestPipelines } from '../../../hooks'; + +import { useEditTransformFlyoutActions } from '../state_management/edit_transform_flyout_state'; +import { useFormField } from '../state_management/selectors/form_field'; import { EditTransformFlyoutFormTextInput } from './edit_transform_flyout_form_text_input'; -import { useEditTransformFlyout } from './use_edit_transform_flyout'; const ingestPipelineLabel = i18n.translate( 'xpack.transform.transformList.editFlyoutFormDestinationIngestPipelineLabel', @@ -25,8 +27,8 @@ const ingestPipelineLabel = i18n.translate( export const EditTransformIngestPipeline: FC = () => { const { euiTheme } = useEuiTheme(); - const { errorMessages, value } = useEditTransformFlyout('destinationIngestPipeline'); - const { formField } = useEditTransformFlyout('actions'); + const { errorMessages, value } = useFormField('destinationIngestPipeline'); + const { setFormField } = useEditTransformFlyoutActions(); const { data: esIngestPipelinesData, isLoading } = useGetEsIngestPipelines(); const ingestPipelineNames = esIngestPipelinesData?.map(({ name }) => name) ?? []; @@ -66,7 +68,7 @@ export const EditTransformIngestPipeline: FC = () => { options={ingestPipelineNames.map((label: string) => ({ label }))} selectedOptions={[{ label: value }]} onChange={(o) => - formField({ field: 'destinationIngestPipeline', value: o[0]?.label ?? '' }) + setFormField({ field: 'destinationIngestPipeline', value: o[0]?.label ?? '' }) } /> diff --git a/x-pack/plugins/transform/public/app/sections/transform_management/components/edit_transform_flyout/edit_transform_retention_policy.tsx b/x-pack/plugins/transform/public/app/sections/edit_transform/components/edit_transform_retention_policy.tsx similarity index 82% rename from x-pack/plugins/transform/public/app/sections/transform_management/components/edit_transform_flyout/edit_transform_retention_policy.tsx rename to x-pack/plugins/transform/public/app/sections/edit_transform/components/edit_transform_retention_policy.tsx index 162cde153b6e7..c32409cb6ff7f 100644 --- a/x-pack/plugins/transform/public/app/sections/transform_management/components/edit_transform_flyout/edit_transform_retention_policy.tsx +++ b/x-pack/plugins/transform/public/app/sections/edit_transform/components/edit_transform_retention_policy.tsx @@ -7,37 +7,45 @@ import React, { useEffect, useMemo, type FC } from 'react'; -import { i18n } from '@kbn/i18n'; - import { EuiFormRow, EuiSelect, EuiSpacer, EuiSwitch } from '@elastic/eui'; + +import { i18n } from '@kbn/i18n'; import { toMountPoint } from '@kbn/react-kibana-mount'; -import { useAppDependencies, useToastNotifications } from '../../../../app_dependencies'; -import { ToastNotificationText } from '../../../../components'; -import { isLatestTransform, isPivotTransform } from '../../../../../../common/types/transform'; -import { useGetTransformsPreview } from '../../../../hooks'; + +import type { PostTransformsPreviewRequestSchema } from '../../../../../common/api_schemas/transforms'; +import { isLatestTransform, isPivotTransform } from '../../../../../common/types/transform'; +import { getErrorMessage } from '../../../../../common/utils/errors'; + +import { useAppDependencies, useToastNotifications } from '../../../app_dependencies'; +import { useGetTransformsPreview } from '../../../hooks'; +import { ToastNotificationText } from '../../../components'; + +import { + useEditTransformFlyoutActions, + useEditTransformFlyoutContext, +} from '../state_management/edit_transform_flyout_state'; +import { useFormSections } from '../state_management/selectors/form_sections'; +import { useRetentionPolicyField } from '../state_management/selectors/retention_policy_field'; import { EditTransformFlyoutFormTextInput } from './edit_transform_flyout_form_text_input'; -import { useEditTransformFlyout } from './use_edit_transform_flyout'; -import { getErrorMessage } from '../../../../../../common/utils/errors'; export const EditTransformRetentionPolicy: FC = () => { const { i18n: i18nStart, theme } = useAppDependencies(); const toastNotifications = useToastNotifications(); - const dataViewId = useEditTransformFlyout('dataViewId'); - const formSections = useEditTransformFlyout('stateFormSection'); - const retentionPolicyField = useEditTransformFlyout('retentionPolicyField'); - const { formField, formSection } = useEditTransformFlyout('actions'); - const requestConfig = useEditTransformFlyout('config'); + const { config, dataViewId } = useEditTransformFlyoutContext(); + const formSections = useFormSections(); + const retentionPolicyField = useRetentionPolicyField(); + const { setFormField, setFormSection } = useEditTransformFlyoutActions(); - const previewRequest = useMemo(() => { + const previewRequest: PostTransformsPreviewRequestSchema = useMemo(() => { return { - source: requestConfig.source, - ...(isPivotTransform(requestConfig) ? { pivot: requestConfig.pivot } : {}), - ...(isLatestTransform(requestConfig) ? { latest: requestConfig.latest } : {}), + source: config.source, + ...(isPivotTransform(config) ? { pivot: config.pivot } : {}), + ...(isLatestTransform(config) ? { latest: config.latest } : {}), }; - }, [requestConfig]); + }, [config]); const { error: transformsPreviewError, data: transformPreview } = useGetTransformsPreview(previewRequest); @@ -98,7 +106,7 @@ export const EditTransformRetentionPolicy: FC = () => { )} checked={formSections.retentionPolicy.enabled} onChange={(e) => - formSection({ + setFormSection({ section: 'retentionPolicy', enabled: e.target.checked, }) @@ -142,7 +150,7 @@ export const EditTransformRetentionPolicy: FC = () => { options={retentionDateFieldOptions} value={retentionPolicyField.value} onChange={(e) => - formField({ field: 'retentionPolicyField', value: e.target.value }) + setFormField({ field: 'retentionPolicyField', value: e.target.value }) } hasNoInitialSelection={ !retentionDateFieldOptions diff --git a/x-pack/plugins/transform/public/app/sections/transform_management/components/edit_transform_flyout/edit_transform_update_button.tsx b/x-pack/plugins/transform/public/app/sections/edit_transform/components/edit_transform_update_button.tsx similarity index 53% rename from x-pack/plugins/transform/public/app/sections/transform_management/components/edit_transform_flyout/edit_transform_update_button.tsx rename to x-pack/plugins/transform/public/app/sections/edit_transform/components/edit_transform_update_button.tsx index b55b6f90a0aa3..6fe5c7fe561b2 100644 --- a/x-pack/plugins/transform/public/app/sections/transform_management/components/edit_transform_flyout/edit_transform_update_button.tsx +++ b/x-pack/plugins/transform/public/app/sections/edit_transform/components/edit_transform_update_button.tsx @@ -11,29 +11,38 @@ import { EuiButton } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; -import { getErrorMessage } from '../../../../../../common/utils/errors'; +import { getErrorMessage } from '../../../../../common/utils/errors'; -import { useUpdateTransform } from '../../../../hooks'; +import { useUpdateTransform } from '../../../hooks'; -import { useEditTransformFlyout } from './use_edit_transform_flyout'; +import { + useEditTransformFlyoutActions, + useEditTransformFlyoutContext, +} from '../state_management/edit_transform_flyout_state'; +import { useIsFormTouched } from '../state_management/selectors/is_form_touched'; +import { useIsFormValid } from '../state_management/selectors/is_form_valid'; +import { useUpdatedTransformConfig } from '../state_management/selectors/updated_transform_config'; interface EditTransformUpdateButtonProps { closeFlyout: () => void; } export const EditTransformUpdateButton: FC = ({ closeFlyout }) => { - const requestConfig = useEditTransformFlyout('requestConfig'); - const isUpdateButtonDisabled = useEditTransformFlyout('isUpdateButtonDisabled'); - const config = useEditTransformFlyout('config'); - const { apiError } = useEditTransformFlyout('actions'); + const { config } = useEditTransformFlyoutContext(); + const isFormValid = useIsFormValid(); + const isFormTouched = useIsFormTouched(); + const requestConfig = useUpdatedTransformConfig(); + const isUpdateButtonDisabled = !isFormValid || !isFormTouched; + + const { setApiError } = useEditTransformFlyoutActions(); const updateTransfrom = useUpdateTransform(config.id, requestConfig); async function submitFormHandler() { - apiError(undefined); + setApiError(undefined); updateTransfrom(undefined, { - onError: (error) => apiError(getErrorMessage(error)), + onError: (error) => setApiError(getErrorMessage(error)), onSuccess: () => closeFlyout(), }); } diff --git a/x-pack/plugins/transform/public/app/sections/transform_management/components/edit_transform_flyout/index.ts b/x-pack/plugins/transform/public/app/sections/edit_transform/index.ts similarity index 77% rename from x-pack/plugins/transform/public/app/sections/transform_management/components/edit_transform_flyout/index.ts rename to x-pack/plugins/transform/public/app/sections/edit_transform/index.ts index 018297f859baa..4ce23cc1f56d7 100644 --- a/x-pack/plugins/transform/public/app/sections/transform_management/components/edit_transform_flyout/index.ts +++ b/x-pack/plugins/transform/public/app/sections/edit_transform/index.ts @@ -5,4 +5,4 @@ * 2.0. */ -export { EditTransformFlyout } from './edit_transform_flyout'; +export { EditTransformFlyout } from './components/edit_transform_flyout'; diff --git a/x-pack/plugins/transform/public/app/sections/edit_transform/state_management/__mocks__/transform_config.ts b/x-pack/plugins/transform/public/app/sections/edit_transform/state_management/__mocks__/transform_config.ts new file mode 100644 index 0000000000000..532f932bfb0b8 --- /dev/null +++ b/x-pack/plugins/transform/public/app/sections/edit_transform/state_management/__mocks__/transform_config.ts @@ -0,0 +1,38 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { TransformPivotConfig } from '../../../../../../common/types/transform'; + +export const getTransformConfigMock = (): TransformPivotConfig => ({ + id: 'the-transform-id', + source: { + index: ['the-transform-source-index'], + query: { + match_all: {}, + }, + }, + dest: { + index: 'the-transform-destination-index', + }, + pivot: { + group_by: { + airline: { + terms: { + field: 'airline', + }, + }, + }, + aggregations: { + 'responsetime.avg': { + avg: { + field: 'responsetime', + }, + }, + }, + }, + description: 'the-description', +}); diff --git a/x-pack/plugins/transform/public/app/sections/edit_transform/state_management/actions.ts b/x-pack/plugins/transform/public/app/sections/edit_transform/state_management/actions.ts new file mode 100644 index 0000000000000..f0ed9342ceb32 --- /dev/null +++ b/x-pack/plugins/transform/public/app/sections/edit_transform/state_management/actions.ts @@ -0,0 +1,79 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { PayloadAction } from '@reduxjs/toolkit'; + +import type { FormFields } from './form_field'; +import type { FormSections } from './form_section'; +import type { ProviderProps, State } from './edit_transform_flyout_state'; +import { getDefaultState } from './get_default_state'; +import { validators, type ValidatorName } from './validators'; + +function isFormFieldOptional(state: State, field: FormFields) { + const formField = state.formFields[field]; + + let isOptional = formField.isOptional; + if (formField.section) { + const section = state.formSections[formField.section]; + if (section.enabled && formField.isOptionalInSection === false) { + isOptional = false; + } + } + + return isOptional; +} + +function getFormFieldErrorMessages( + value: string, + isOptional: boolean, + validatorName: ValidatorName +) { + return isOptional && typeof value === 'string' && value.length === 0 + ? [] + : validators[validatorName](value, isOptional); +} + +export const initialize = (_: State, action: PayloadAction) => + getDefaultState(action.payload.config); + +export const setApiError = (state: State, action: PayloadAction) => { + state.apiErrorMessage = action.payload; +}; + +export const setFormField = ( + state: State, + action: PayloadAction<{ field: FormFields; value: string }> +) => { + const formField = state.formFields[action.payload.field]; + const isOptional = isFormFieldOptional(state, action.payload.field); + + formField.errorMessages = getFormFieldErrorMessages( + action.payload.value, + isOptional, + formField.validator + ); + + formField.value = action.payload.value; +}; + +export const setFormSection = ( + state: State, + action: PayloadAction<{ section: FormSections; enabled: boolean }> +) => { + state.formSections[action.payload.section].enabled = action.payload.enabled; + + // After a section change we re-evaluate all form fields, since optionality + // of a field could change if a section got toggled. + Object.entries(state.formFields).forEach(([formFieldName, formField]) => { + const isOptional = isFormFieldOptional(state, formFieldName as FormFields); + formField.errorMessages = getFormFieldErrorMessages( + formField.value, + isOptional, + formField.validator + ); + }); +}; diff --git a/x-pack/plugins/transform/public/app/sections/transform_management/components/edit_transform_flyout/use_edit_transform_flyout.test.ts b/x-pack/plugins/transform/public/app/sections/edit_transform/state_management/apply_form_state_to_transform_config.test.ts similarity index 61% rename from x-pack/plugins/transform/public/app/sections/transform_management/components/edit_transform_flyout/use_edit_transform_flyout.test.ts rename to x-pack/plugins/transform/public/app/sections/edit_transform/state_management/apply_form_state_to_transform_config.test.ts index ebea339c44300..4586760fb9b6e 100644 --- a/x-pack/plugins/transform/public/app/sections/transform_management/components/edit_transform_flyout/use_edit_transform_flyout.test.ts +++ b/x-pack/plugins/transform/public/app/sections/edit_transform/state_management/apply_form_state_to_transform_config.test.ts @@ -5,51 +5,22 @@ * 2.0. */ -import { TransformPivotConfig } from '../../../../../../common/types/transform'; - -import { - applyFormStateToTransformConfig, - formReducerFactory, - getDefaultState, -} from './use_edit_transform_flyout'; - -const getTransformConfigMock = (): TransformPivotConfig => ({ - id: 'the-transform-id', - source: { - index: ['the-transform-source-index'], - query: { - match_all: {}, - }, - }, - dest: { - index: 'the-transform-destination-index', - }, - pivot: { - group_by: { - airline: { - terms: { - field: 'airline', - }, - }, - }, - aggregations: { - 'responsetime.avg': { - avg: { - field: 'responsetime', - }, - }, - }, - }, - description: 'the-description', -}); +import { getTransformConfigMock } from './__mocks__/transform_config'; + +import { applyFormStateToTransformConfig } from './apply_form_state_to_transform_config'; +import { getDefaultState } from './get_default_state'; describe('Transform: applyFormStateToTransformConfig()', () => { it('should exclude unchanged form fields', () => { const transformConfigMock = getTransformConfigMock(); - const formState = getDefaultState(transformConfigMock); + const { formFields, formSections } = getDefaultState(transformConfigMock); - const updateConfig = applyFormStateToTransformConfig(transformConfigMock, formState); + const updateConfig = applyFormStateToTransformConfig( + transformConfigMock, + formFields, + formSections + ); // This case will return an empty object. In the actual UI, this case should not happen // because the Update-Button will be disabled when no form field was changed. @@ -65,7 +36,7 @@ describe('Transform: applyFormStateToTransformConfig()', () => { it('should include previously nonexisting attributes', () => { const { description, frequency, ...transformConfigMock } = getTransformConfigMock(); - const formState = getDefaultState({ + const { formFields, formSections } = getDefaultState({ ...transformConfigMock, description: 'the-new-description', dest: { @@ -77,7 +48,11 @@ describe('Transform: applyFormStateToTransformConfig()', () => { }, }); - const updateConfig = applyFormStateToTransformConfig(transformConfigMock, formState); + const updateConfig = applyFormStateToTransformConfig( + transformConfigMock, + formFields, + formSections + ); expect(Object.keys(updateConfig)).toHaveLength(4); expect(updateConfig.description).toBe('the-new-description'); @@ -89,7 +64,7 @@ describe('Transform: applyFormStateToTransformConfig()', () => { it('should only include changed form fields', () => { const transformConfigMock = getTransformConfigMock(); - const formState = getDefaultState({ + const { formFields, formSections } = getDefaultState({ ...transformConfigMock, description: 'the-updated-description', dest: { @@ -98,7 +73,11 @@ describe('Transform: applyFormStateToTransformConfig()', () => { }, }); - const updateConfig = applyFormStateToTransformConfig(transformConfigMock, formState); + const updateConfig = applyFormStateToTransformConfig( + transformConfigMock, + formFields, + formSections + ); expect(Object.keys(updateConfig)).toHaveLength(2); expect(updateConfig.description).toBe('the-updated-description'); @@ -111,7 +90,7 @@ describe('Transform: applyFormStateToTransformConfig()', () => { it('should include dependent form fields', () => { const transformConfigMock = getTransformConfigMock(); - const formState = getDefaultState({ + const { formFields, formSections } = getDefaultState({ ...transformConfigMock, dest: { ...transformConfigMock.dest, @@ -119,7 +98,11 @@ describe('Transform: applyFormStateToTransformConfig()', () => { }, }); - const updateConfig = applyFormStateToTransformConfig(transformConfigMock, formState); + const updateConfig = applyFormStateToTransformConfig( + transformConfigMock, + formFields, + formSections + ); expect(Object.keys(updateConfig)).toHaveLength(1); // It should include the dependent unchanged destination index expect(updateConfig.dest?.index).toBe(transformConfigMock.dest.index); @@ -135,7 +118,7 @@ describe('Transform: applyFormStateToTransformConfig()', () => { }, }; - const formState = getDefaultState({ + const { formFields, formSections } = getDefaultState({ ...transformConfigMock, dest: { ...transformConfigMock.dest, @@ -143,7 +126,11 @@ describe('Transform: applyFormStateToTransformConfig()', () => { }, }); - const updateConfig = applyFormStateToTransformConfig(transformConfigMock, formState); + const updateConfig = applyFormStateToTransformConfig( + transformConfigMock, + formFields, + formSections + ); expect(Object.keys(updateConfig)).toHaveLength(1); // It should include the dependent unchanged destination index expect(updateConfig.dest?.index).toBe(transformConfigMock.dest.index); @@ -153,12 +140,16 @@ describe('Transform: applyFormStateToTransformConfig()', () => { it('should exclude unrelated dependent form fields', () => { const transformConfigMock = getTransformConfigMock(); - const formState = getDefaultState({ + const { formFields, formSections } = getDefaultState({ ...transformConfigMock, description: 'the-updated-description', }); - const updateConfig = applyFormStateToTransformConfig(transformConfigMock, formState); + const updateConfig = applyFormStateToTransformConfig( + transformConfigMock, + formFields, + formSections + ); expect(Object.keys(updateConfig)).toHaveLength(1); // It should exclude the dependent unchanged destination section expect(typeof updateConfig.dest).toBe('undefined'); @@ -168,16 +159,20 @@ describe('Transform: applyFormStateToTransformConfig()', () => { it('should return the config to reset retention policy', () => { const transformConfigMock = getTransformConfigMock(); - const formState = getDefaultState({ + const { formFields, formSections } = getDefaultState({ ...transformConfigMock, retention_policy: { time: { field: 'the-time-field', max_age: '1d' }, }, }); - formState.formSections.retentionPolicy.enabled = false; + formSections.retentionPolicy.enabled = false; - const updateConfig = applyFormStateToTransformConfig(transformConfigMock, formState); + const updateConfig = applyFormStateToTransformConfig( + transformConfigMock, + formFields, + formSections + ); expect(Object.keys(updateConfig)).toHaveLength(1); // It should exclude the dependent unchanged destination section @@ -185,46 +180,3 @@ describe('Transform: applyFormStateToTransformConfig()', () => { expect(updateConfig.retention_policy).toBe(null); }); }); - -describe('Transform: formReducerFactory()', () => { - it('field updates should trigger form validation', () => { - const transformConfigMock = getTransformConfigMock(); - const reducer = formReducerFactory(transformConfigMock); - - const state1 = reducer(getDefaultState(transformConfigMock), { - name: 'form_field', - payload: { - field: 'description', - value: 'the-updated-description', - }, - }); - - expect(state1.isFormTouched).toBe(true); - expect(state1.isFormValid).toBe(true); - - const state2 = reducer(state1, { - name: 'form_field', - payload: { - field: 'description', - value: transformConfigMock.description as string, - }, - }); - - expect(state2.isFormTouched).toBe(false); - expect(state2.isFormValid).toBe(true); - - const state3 = reducer(state2, { - name: 'form_field', - payload: { - field: 'frequency', - value: 'the-invalid-value', - }, - }); - - expect(state3.isFormTouched).toBe(true); - expect(state3.isFormValid).toBe(false); - expect(state3.formFields.frequency.errorMessages).toStrictEqual([ - 'The frequency value is not valid.', - ]); - }); -}); diff --git a/x-pack/plugins/transform/public/app/sections/edit_transform/state_management/apply_form_state_to_transform_config.ts b/x-pack/plugins/transform/public/app/sections/edit_transform/state_management/apply_form_state_to_transform_config.ts new file mode 100644 index 0000000000000..2e0f7d05ad287 --- /dev/null +++ b/x-pack/plugins/transform/public/app/sections/edit_transform/state_management/apply_form_state_to_transform_config.ts @@ -0,0 +1,37 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { merge } from 'lodash'; + +import type { PostTransformsUpdateRequestSchema } from '../../../../../common/api_schemas/update_transforms'; +import type { TransformConfigUnion } from '../../../../../common/types/transform'; + +import { getUpdateValue } from './get_update_value'; + +import type { FormFields, FormFieldsState } from './form_field'; +import type { FormSectionsState } from './form_section'; + +// Takes in the form configuration and returns a request object suitable to be sent to the +// transform update API endpoint by iterating over `getUpdateValue()`. +// Once a user hits the update button, this function takes care of extracting the information +// necessary to create the update request. They take into account whether a field needs to +// be included at all in the request (for example, if it hadn't been changed). +// The code is also able to identify relationships/dependencies between form fields. +// For example, if the `pipeline` field was changed, it's necessary to make the `index` +// field part of the request, otherwise the update would fail. +export const applyFormStateToTransformConfig = ( + config: TransformConfigUnion, + formFields: FormFieldsState, + formSections: FormSectionsState +): PostTransformsUpdateRequestSchema => + // Iterates over all form fields and only if necessary applies them to + // the request object used for updating the transform. + (Object.keys(formFields) as FormFields[]).reduce( + (updateConfig, field) => + merge({ ...updateConfig }, getUpdateValue(field, config, formFields, formSections)), + {} + ); diff --git a/x-pack/plugins/transform/public/app/sections/edit_transform/state_management/edit_transform_flyout_state.test.tsx b/x-pack/plugins/transform/public/app/sections/edit_transform/state_management/edit_transform_flyout_state.test.tsx new file mode 100644 index 0000000000000..67f1a49e6ec64 --- /dev/null +++ b/x-pack/plugins/transform/public/app/sections/edit_transform/state_management/edit_transform_flyout_state.test.tsx @@ -0,0 +1,75 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { type FC } from 'react'; +import { act, renderHook } from '@testing-library/react-hooks'; + +import { getTransformConfigMock } from './__mocks__/transform_config'; + +import { + useEditTransformFlyoutActions, + EditTransformFlyoutProvider, +} from './edit_transform_flyout_state'; +import { useFormField } from './selectors/form_field'; +import { useIsFormTouched } from './selectors/is_form_touched'; +import { useIsFormValid } from './selectors/is_form_valid'; + +describe('Transform: useEditTransformFlyoutActions/Selector()', () => { + it('field updates should trigger form validation', () => { + const transformConfigMock = getTransformConfigMock(); + const wrapper: FC = ({ children }) => ( + + {children} + + ); + + // As we want to test how actions affect the state, + // we set up this custom hook that combines hooks for + // actions and state selection, so they react to the same redux store. + const useHooks = () => ({ + actions: useEditTransformFlyoutActions(), + isFormTouched: useIsFormTouched(), + isFormValid: useIsFormValid(), + frequency: useFormField('frequency'), + }); + + const { result } = renderHook(useHooks, { wrapper }); + + act(() => { + result.current.actions.setFormField({ + field: 'description', + value: 'the-updated-description', + }); + }); + + expect(result.current.isFormTouched).toBe(true); + expect(result.current.isFormValid).toBe(true); + + act(() => { + result.current.actions.setFormField({ + field: 'description', + value: transformConfigMock.description as string, + }); + }); + + expect(result.current.isFormTouched).toBe(false); + expect(result.current.isFormValid).toBe(true); + + act(() => { + result.current.actions.setFormField({ + field: 'frequency', + value: 'the-invalid-value', + }); + }); + + expect(result.current.isFormTouched).toBe(true); + expect(result.current.isFormValid).toBe(false); + expect(result.current.frequency.errorMessages).toStrictEqual([ + 'The frequency value is not valid.', + ]); + }); +}); diff --git a/x-pack/plugins/transform/public/app/sections/edit_transform/state_management/edit_transform_flyout_state.tsx b/x-pack/plugins/transform/public/app/sections/edit_transform/state_management/edit_transform_flyout_state.tsx new file mode 100644 index 0000000000000..b79fbd55362f6 --- /dev/null +++ b/x-pack/plugins/transform/public/app/sections/edit_transform/state_management/edit_transform_flyout_state.tsx @@ -0,0 +1,83 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { createContext, useContext, useMemo, type FC, type PropsWithChildren } from 'react'; +import { configureStore, createSlice } from '@reduxjs/toolkit'; +import { useDispatch, Provider } from 'react-redux'; +import { bindActionCreators } from 'redux'; +import useMount from 'react-use/lib/useMount'; + +import type { TransformConfigUnion } from '../../../../../common/types/transform'; + +import { initialize, setApiError, setFormField, setFormSection } from './actions'; +import { type FormFieldsState } from './form_field'; +import { type FormSectionsState } from './form_section'; +import { getDefaultState } from './get_default_state'; + +// The edit transform flyout uses a redux-toolkit to manage its form state with +// support for applying its state to a nested configuration object suitable for passing on +// directly to the API call. For now this is only used for the transform edit form. +// Once we apply the functionality to other places, e.g. the transform creation wizard, +// the generic framework code in this file should be moved to a dedicated location. + +export interface ProviderProps { + config: TransformConfigUnion; + dataViewId?: string; +} + +export interface State { + apiErrorMessage?: string; + formFields: FormFieldsState; + formSections: FormSectionsState; +} + +const editTransformFlyoutSlice = createSlice({ + name: 'editTransformFlyout', + initialState: getDefaultState(), + reducers: { + initialize, + setApiError, + setFormField, + setFormSection, + }, +}); + +const getReduxStore = () => + configureStore({ + reducer: editTransformFlyoutSlice.reducer, + }); + +const EditTransformFlyoutContext = createContext(null); + +export const EditTransformFlyoutProvider: FC> = ({ + children, + ...props +}) => { + const store = useMemo(getReduxStore, []); + + // Apply original transform config to redux form state. + useMount(() => { + store.dispatch(editTransformFlyoutSlice.actions.initialize(props)); + }); + + return ( + + {children} + + ); +}; + +export const useEditTransformFlyoutContext = () => { + const c = useContext(EditTransformFlyoutContext); + if (c === null) throw new Error('EditTransformFlyoutContext not set.'); + return c; +}; + +export const useEditTransformFlyoutActions = () => { + const dispatch = useDispatch(); + return useMemo(() => bindActionCreators(editTransformFlyoutSlice.actions, dispatch), [dispatch]); +}; diff --git a/x-pack/plugins/transform/public/app/sections/edit_transform/state_management/form_field.ts b/x-pack/plugins/transform/public/app/sections/edit_transform/state_management/form_field.ts new file mode 100644 index 0000000000000..428f50d289c61 --- /dev/null +++ b/x-pack/plugins/transform/public/app/sections/edit_transform/state_management/form_field.ts @@ -0,0 +1,70 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { getNestedProperty } from '@kbn/ml-nested-property'; + +import type { TransformConfigUnion } from '../../../../../common/types/transform'; + +import type { FormSections } from './form_section'; +import type { ValidatorName } from './validators'; +import type { ValueParserName } from './value_parsers'; + +// The form state defines a flat structure of names for form fields. +// This is a flat structure regardless of whether the final config object will be nested. +// For example, `destinationIndex` and `destinationIngestPipeline` will later be nested under `dest`. +export type FormFields = + | 'description' + | 'destinationIndex' + | 'destinationIngestPipeline' + | 'docsPerSecond' + | 'frequency' + | 'maxPageSearchSize' + | 'numFailureRetries' + | 'retentionPolicyField' + | 'retentionPolicyMaxAge'; + +export type FormFieldsState = Record; + +export interface FormField { + formFieldName: FormFields; + configFieldName: string; + defaultValue: string; + dependsOn: FormFields[]; + errorMessages: string[]; + isNullable: boolean; + isOptional: boolean; + isOptionalInSection?: boolean; + section?: FormSections; + validator: ValidatorName; + value: string; + valueParser: ValueParserName; +} + +export const initializeFormField = ( + formFieldName: FormFields, + configFieldName: string, + config?: TransformConfigUnion, + overloads?: Partial +): FormField => { + const defaultValue = overloads?.defaultValue !== undefined ? overloads.defaultValue : ''; + const rawValue = getNestedProperty(config ?? {}, configFieldName, undefined); + const value = rawValue !== null && rawValue !== undefined ? rawValue.toString() : ''; + + return { + formFieldName, + configFieldName, + defaultValue, + dependsOn: [], + errorMessages: [], + isNullable: false, + isOptional: true, + validator: 'stringValidator', + value, + valueParser: 'defaultParser', + ...(overloads !== undefined ? { ...overloads } : {}), + }; +}; diff --git a/x-pack/plugins/transform/public/app/sections/edit_transform/state_management/form_section.ts b/x-pack/plugins/transform/public/app/sections/edit_transform/state_management/form_section.ts new file mode 100644 index 0000000000000..7f7c49b19d5f4 --- /dev/null +++ b/x-pack/plugins/transform/public/app/sections/edit_transform/state_management/form_section.ts @@ -0,0 +1,45 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { isDefined } from '@kbn/ml-is-defined'; +import { getNestedProperty } from '@kbn/ml-nested-property'; + +import type { TransformConfigUnion } from '../../../../../common/types/transform'; + +// Defining these sections is only necessary for options where a reset/deletion of that part of the +// configuration is supported by the API. For example, this isn't suitable to use with `dest` since +// this overall part of the configuration is not optional. However, `retention_policy` is optional, +// so we need to support to recognize this based on the form state and be able to reset it by +// creating a request body containing `{ retention_policy: null }`. +export type FormSections = 'retentionPolicy'; + +export interface FormSection { + formSectionName: FormSections; + configFieldName: string; + defaultEnabled: boolean; + enabled: boolean; +} + +export type FormSectionsState = Record; + +export const initializeFormSection = ( + formSectionName: FormSections, + configFieldName: string, + config?: TransformConfigUnion, + overloads?: Partial +): FormSection => { + const defaultEnabled = overloads?.defaultEnabled ?? false; + const rawEnabled = getNestedProperty(config ?? {}, configFieldName, undefined); + const enabled = isDefined(rawEnabled); + + return { + formSectionName, + configFieldName, + defaultEnabled, + enabled, + }; +}; diff --git a/x-pack/plugins/transform/public/app/sections/edit_transform/state_management/get_default_state.ts b/x-pack/plugins/transform/public/app/sections/edit_transform/state_management/get_default_state.ts new file mode 100644 index 0000000000000..859000fdc0ecf --- /dev/null +++ b/x-pack/plugins/transform/public/app/sections/edit_transform/state_management/get_default_state.ts @@ -0,0 +1,109 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { + DEFAULT_TRANSFORM_FREQUENCY, + DEFAULT_TRANSFORM_SETTINGS_MAX_PAGE_SEARCH_SIZE, +} from '../../../../../common/constants'; +import type { TransformConfigUnion } from '../../../../../common/types/transform'; + +import { initializeFormField } from './form_field'; +import { initializeFormSection } from './form_section'; +import type { State } from './edit_transform_flyout_state'; + +// Takes in a transform configuration and returns the default state to populate the form. +// It supports overrides to apply a pre-existing configuration. +// The implementation of this function is the only one that's specifically required to define +// the features of the transform edit form. All other functions are generic and could be reused +// in the future for other forms. +export const getDefaultState = (config?: TransformConfigUnion): State => ({ + formFields: { + // top level attributes + description: initializeFormField('description', 'description', config), + frequency: initializeFormField('frequency', 'frequency', config, { + defaultValue: DEFAULT_TRANSFORM_FREQUENCY, + validator: 'frequencyValidator', + }), + + // dest.* + destinationIndex: initializeFormField('destinationIndex', 'dest.index', config, { + dependsOn: ['destinationIngestPipeline'], + isOptional: false, + }), + destinationIngestPipeline: initializeFormField( + 'destinationIngestPipeline', + 'dest.pipeline', + config, + { + dependsOn: ['destinationIndex'], + isOptional: true, + } + ), + + // settings.* + docsPerSecond: initializeFormField('docsPerSecond', 'settings.docs_per_second', config, { + isNullable: true, + isOptional: true, + validator: 'integerAboveZeroValidator', + valueParser: 'nullableNumberParser', + }), + maxPageSearchSize: initializeFormField( + 'maxPageSearchSize', + 'settings.max_page_search_size', + config, + { + defaultValue: `${DEFAULT_TRANSFORM_SETTINGS_MAX_PAGE_SEARCH_SIZE}`, + isNullable: true, + isOptional: true, + validator: 'transformSettingsPageSearchSizeValidator', + valueParser: 'numberParser', + } + ), + numFailureRetries: initializeFormField( + 'numFailureRetries', + 'settings.num_failure_retries', + config, + { + defaultValue: undefined, + isNullable: true, + isOptional: true, + validator: 'transformSettingsNumberOfRetriesValidator', + valueParser: 'numberParser', + } + ), + + // retention_policy.* + retentionPolicyField: initializeFormField( + 'retentionPolicyField', + 'retention_policy.time.field', + config, + { + dependsOn: ['retentionPolicyMaxAge'], + isNullable: false, + isOptional: true, + isOptionalInSection: false, + section: 'retentionPolicy', + } + ), + retentionPolicyMaxAge: initializeFormField( + 'retentionPolicyMaxAge', + 'retention_policy.time.max_age', + config, + { + dependsOn: ['retentionPolicyField'], + isNullable: false, + isOptional: true, + isOptionalInSection: false, + section: 'retentionPolicy', + validator: 'retentionPolicyMaxAgeValidator', + } + ), + }, + formSections: { + retentionPolicy: initializeFormSection('retentionPolicy', 'retention_policy', config), + }, +}); diff --git a/x-pack/plugins/transform/public/app/sections/edit_transform/state_management/get_update_value.ts b/x-pack/plugins/transform/public/app/sections/edit_transform/state_management/get_update_value.ts new file mode 100644 index 0000000000000..82c6f012cdaf9 --- /dev/null +++ b/x-pack/plugins/transform/public/app/sections/edit_transform/state_management/get_update_value.ts @@ -0,0 +1,80 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { merge } from 'lodash'; + +import { getNestedProperty, setNestedProperty } from '@kbn/ml-nested-property'; + +import type { PostTransformsUpdateRequestSchema } from '../../../../../common/api_schemas/update_transforms'; +import type { TransformConfigUnion } from '../../../../../common/types/transform'; + +import type { FormFields, FormFieldsState } from './form_field'; +import type { FormSectionsState } from './form_section'; +import { valueParsers } from './value_parsers'; + +// Takes a value from form state and applies it to the structure +// of the expected final configuration request object. +// Considers options like if a value is nullable or optional. +export const getUpdateValue = ( + attribute: FormFields, + config: TransformConfigUnion, + formFields: FormFieldsState, + formSections: FormSectionsState, + enforceFormValue = false +) => { + const formStateAttribute = formFields[attribute]; + const fallbackValue = formStateAttribute.isNullable ? null : formStateAttribute.defaultValue; + + const enabledBasedOnSection = + formStateAttribute.section !== undefined + ? formSections[formStateAttribute.section].enabled + : true; + + const formValue = + formStateAttribute.value !== '' + ? valueParsers[formStateAttribute.valueParser](formStateAttribute.value) + : fallbackValue; + + const configValue = getNestedProperty(config, formStateAttribute.configFieldName, fallbackValue); + + // only get depending values if we're not already in a call to get depending values. + const dependsOnConfig: PostTransformsUpdateRequestSchema = + enforceFormValue === false + ? formStateAttribute.dependsOn.reduce((_dependsOnConfig, dependsOnField) => { + return merge( + { ..._dependsOnConfig }, + getUpdateValue(dependsOnField, config, formFields, formSections, true) + ); + }, {}) + : {}; + + if ( + formValue === formStateAttribute.defaultValue && + formValue === configValue && + formStateAttribute.isOptional + ) { + return {}; + } + + // If the resettable section the form field belongs to is disabled, + // the whole section will be set to `null` to do the actual reset. + if (formStateAttribute.section !== undefined && !enabledBasedOnSection) { + return setNestedProperty( + dependsOnConfig, + formSections[formStateAttribute.section].configFieldName, + null + ); + } + + return enabledBasedOnSection && (formValue !== configValue || enforceFormValue) + ? setNestedProperty( + dependsOnConfig, + formStateAttribute.configFieldName, + formValue === '' && formStateAttribute.isOptional ? undefined : formValue + ) + : {}; +}; diff --git a/x-pack/plugins/transform/public/app/sections/edit_transform/state_management/selectors/api_error_message.ts b/x-pack/plugins/transform/public/app/sections/edit_transform/state_management/selectors/api_error_message.ts new file mode 100644 index 0000000000000..710cb3d8a5f5b --- /dev/null +++ b/x-pack/plugins/transform/public/app/sections/edit_transform/state_management/selectors/api_error_message.ts @@ -0,0 +1,15 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useSelector } from 'react-redux'; + +import type { State } from '../edit_transform_flyout_state'; + +const selectApiErrorMessage = (s: State) => s.apiErrorMessage; +export const useApiErrorMessage = () => { + return useSelector(selectApiErrorMessage); +}; diff --git a/x-pack/plugins/transform/public/app/sections/edit_transform/state_management/selectors/form_field.ts b/x-pack/plugins/transform/public/app/sections/edit_transform/state_management/selectors/form_field.ts new file mode 100644 index 0000000000000..920bb67cb1154 --- /dev/null +++ b/x-pack/plugins/transform/public/app/sections/edit_transform/state_management/selectors/form_field.ts @@ -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 + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useMemo } from 'react'; + +import { useSelector } from 'react-redux'; + +import type { State } from '../edit_transform_flyout_state'; + +import type { FormFields } from '../form_field'; + +export const selectFormFields = (s: State) => s.formFields; + +const createSelectFormField = (field: FormFields) => (s: State) => s.formFields[field]; +export const useFormField = (field: FormFields) => { + const selectFormField = useMemo(() => createSelectFormField(field), [field]); + return useSelector(selectFormField); +}; diff --git a/x-pack/plugins/transform/public/app/sections/edit_transform/state_management/selectors/form_sections.ts b/x-pack/plugins/transform/public/app/sections/edit_transform/state_management/selectors/form_sections.ts new file mode 100644 index 0000000000000..e56f48aa21a5e --- /dev/null +++ b/x-pack/plugins/transform/public/app/sections/edit_transform/state_management/selectors/form_sections.ts @@ -0,0 +1,15 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useSelector } from 'react-redux'; + +import type { State } from '../edit_transform_flyout_state'; + +export const selectFormSections = (s: State) => s.formSections; +export const useFormSections = () => { + return useSelector(selectFormSections); +}; diff --git a/x-pack/plugins/transform/public/app/sections/edit_transform/state_management/selectors/is_form_touched.ts b/x-pack/plugins/transform/public/app/sections/edit_transform/state_management/selectors/is_form_touched.ts new file mode 100644 index 0000000000000..043aea42898fb --- /dev/null +++ b/x-pack/plugins/transform/public/app/sections/edit_transform/state_management/selectors/is_form_touched.ts @@ -0,0 +1,49 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { isEqual } from 'lodash'; +import { useMemo } from 'react'; + +import { createSelector } from '@reduxjs/toolkit'; +import { useSelector } from 'react-redux'; + +import type { TransformConfigUnion } from '../../../../../../common/types/transform'; + +import type { FormFieldsState } from '../form_field'; +import type { FormSectionsState } from '../form_section'; +import { getDefaultState } from '../get_default_state'; +import { useEditTransformFlyoutContext } from '../edit_transform_flyout_state'; + +import { selectFormFields } from './form_field'; +import { selectFormSections } from './form_sections'; + +const getFieldValues = (fields: FormFieldsState) => Object.values(fields).map((f) => f.value); +const getSectionValues = (sections: FormSectionsState) => + Object.values(sections).map((s) => s.enabled); + +const isFormTouched = ( + config: TransformConfigUnion, + formFields: FormFieldsState, + formSections: FormSectionsState +) => { + const defaultState = getDefaultState(config); + return ( + !isEqual(getFieldValues(defaultState.formFields), getFieldValues(formFields)) || + !isEqual(getSectionValues(defaultState.formSections), getSectionValues(formSections)) + ); +}; + +const createSelectIsFormTouched = (originalConfig: TransformConfigUnion) => + createSelector(selectFormFields, selectFormSections, (formFields, formSections) => + isFormTouched(originalConfig, formFields, formSections) + ); + +export const useIsFormTouched = () => { + const { config } = useEditTransformFlyoutContext(); + const selectIsFormTouched = useMemo(() => createSelectIsFormTouched(config), [config]); + return useSelector(selectIsFormTouched); +}; diff --git a/x-pack/plugins/transform/public/app/sections/edit_transform/state_management/selectors/is_form_valid.ts b/x-pack/plugins/transform/public/app/sections/edit_transform/state_management/selectors/is_form_valid.ts new file mode 100644 index 0000000000000..2fa91d65c7be0 --- /dev/null +++ b/x-pack/plugins/transform/public/app/sections/edit_transform/state_management/selectors/is_form_valid.ts @@ -0,0 +1,20 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { createSelector } from '@reduxjs/toolkit'; +import { useSelector } from 'react-redux'; + +import type { FormFieldsState } from '../form_field'; + +import { selectFormFields } from './form_field'; + +// Checks each form field for error messages to return +// if the overall form is valid or not. +const isFormValid = (formFields: FormFieldsState) => + Object.values(formFields).every((d) => d.errorMessages.length === 0); +const selectIsFormValid = createSelector(selectFormFields, isFormValid); +export const useIsFormValid = () => useSelector(selectIsFormValid); diff --git a/x-pack/plugins/transform/public/app/sections/edit_transform/state_management/selectors/retention_policy_field.ts b/x-pack/plugins/transform/public/app/sections/edit_transform/state_management/selectors/retention_policy_field.ts new file mode 100644 index 0000000000000..f899ff3426694 --- /dev/null +++ b/x-pack/plugins/transform/public/app/sections/edit_transform/state_management/selectors/retention_policy_field.ts @@ -0,0 +1,15 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useSelector } from 'react-redux'; + +import type { State } from '../edit_transform_flyout_state'; + +export const selectRetentionPolicyField = (s: State) => s.formFields.retentionPolicyField; +export const useRetentionPolicyField = () => { + return useSelector(selectRetentionPolicyField); +}; diff --git a/x-pack/plugins/transform/public/app/sections/edit_transform/state_management/selectors/updated_transform_config.ts b/x-pack/plugins/transform/public/app/sections/edit_transform/state_management/selectors/updated_transform_config.ts new file mode 100644 index 0000000000000..a7d82c75746f1 --- /dev/null +++ b/x-pack/plugins/transform/public/app/sections/edit_transform/state_management/selectors/updated_transform_config.ts @@ -0,0 +1,29 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useMemo } from 'react'; +import { createSelector } from '@reduxjs/toolkit'; +import { useSelector } from 'react-redux'; + +import { TransformConfigUnion } from '../../../../../../common/types/transform'; + +import { applyFormStateToTransformConfig } from '../apply_form_state_to_transform_config'; +import { useEditTransformFlyoutContext } from '../edit_transform_flyout_state'; + +import { selectFormFields } from './form_field'; +import { selectFormSections } from './form_sections'; + +const createSelectTransformConfig = (originalConfig: TransformConfigUnion) => + createSelector(selectFormFields, selectFormSections, (formFields, formSections) => + applyFormStateToTransformConfig(originalConfig, formFields, formSections) + ); + +export const useUpdatedTransformConfig = () => { + const { config } = useEditTransformFlyoutContext(); + const selectTransformConfig = useMemo(() => createSelectTransformConfig(config), [config]); + return useSelector(selectTransformConfig); +}; diff --git a/x-pack/plugins/transform/public/app/sections/edit_transform/state_management/validators.ts b/x-pack/plugins/transform/public/app/sections/edit_transform/state_management/validators.ts new file mode 100644 index 0000000000000..82ef6476a31a1 --- /dev/null +++ b/x-pack/plugins/transform/public/app/sections/edit_transform/state_management/validators.ts @@ -0,0 +1,25 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { + frequencyValidator, + integerAboveZeroValidator, + transformSettingsNumberOfRetriesValidator, + transformSettingsPageSearchSizeValidator, + retentionPolicyMaxAgeValidator, + stringValidator, +} from '../../../common/validators'; + +export const validators = { + frequencyValidator, + integerAboveZeroValidator, + transformSettingsNumberOfRetriesValidator, + transformSettingsPageSearchSizeValidator, + retentionPolicyMaxAgeValidator, + stringValidator, +}; +export type ValidatorName = keyof typeof validators; diff --git a/x-pack/plugins/transform/public/app/sections/edit_transform/state_management/value_parsers.ts b/x-pack/plugins/transform/public/app/sections/edit_transform/state_management/value_parsers.ts new file mode 100644 index 0000000000000..f04547828e781 --- /dev/null +++ b/x-pack/plugins/transform/public/app/sections/edit_transform/state_management/value_parsers.ts @@ -0,0 +1,19 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +// Note on the form validation and input components used: +// All inputs use `EuiFieldText` which means all form values will be treated as strings. +// This means we cast other formats like numbers coming from the transform config to strings, +// then revalidate them and cast them again to number before submitting a transform update. +// We do this so we have fine grained control over field validation and the option to +// cast to special values like `null` for disabling `docs_per_second`. +export const valueParsers = { + defaultParser: (v: string) => v, + nullableNumberParser: (v: string) => (v === '' ? null : +v), + numberParser: (v: string) => +v, +}; +export type ValueParserName = keyof typeof valueParsers; diff --git a/x-pack/plugins/transform/public/app/sections/transform_management/components/edit_transform_flyout/capitalize_first_letter.ts b/x-pack/plugins/transform/public/app/sections/edit_transform/utils/capitalize_first_letter.ts similarity index 100% rename from x-pack/plugins/transform/public/app/sections/transform_management/components/edit_transform_flyout/capitalize_first_letter.ts rename to x-pack/plugins/transform/public/app/sections/edit_transform/utils/capitalize_first_letter.ts diff --git a/x-pack/plugins/transform/public/app/sections/transform_management/components/edit_transform_flyout/edit_transform_api_error_callout.tsx b/x-pack/plugins/transform/public/app/sections/transform_management/components/edit_transform_flyout/edit_transform_api_error_callout.tsx deleted file mode 100644 index 6713ab8ac530d..0000000000000 --- a/x-pack/plugins/transform/public/app/sections/transform_management/components/edit_transform_flyout/edit_transform_api_error_callout.tsx +++ /dev/null @@ -1,40 +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 - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React, { type FC } from 'react'; - -import { EuiCallOut, EuiSpacer } from '@elastic/eui'; - -import { i18n } from '@kbn/i18n'; - -import { useEditTransformFlyout } from './use_edit_transform_flyout'; - -export const EditTransformApiErrorCallout: FC = () => { - const apiErrorMessage = useEditTransformFlyout('apiErrorMessage'); - - return ( - <> - {apiErrorMessage !== undefined && ( - <> - - -

{apiErrorMessage}

-
- - )} - - ); -}; diff --git a/x-pack/plugins/transform/public/app/sections/transform_management/components/edit_transform_flyout/use_edit_transform_flyout.tsx b/x-pack/plugins/transform/public/app/sections/transform_management/components/edit_transform_flyout/use_edit_transform_flyout.tsx deleted file mode 100644 index 2e82edb54b6fd..0000000000000 --- a/x-pack/plugins/transform/public/app/sections/transform_management/components/edit_transform_flyout/use_edit_transform_flyout.tsx +++ /dev/null @@ -1,514 +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 - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import constate from 'constate'; -import { isEqual, merge } from 'lodash'; -import { useMemo, useReducer } from 'react'; - -import { getNestedProperty, setNestedProperty } from '@kbn/ml-nested-property'; - -import { PostTransformsUpdateRequestSchema } from '../../../../../../common/api_schemas/update_transforms'; -import { - DEFAULT_TRANSFORM_FREQUENCY, - DEFAULT_TRANSFORM_SETTINGS_MAX_PAGE_SEARCH_SIZE, -} from '../../../../../../common/constants'; -import { TransformConfigUnion } from '../../../../../../common/types/transform'; - -// Note on the form validation and input components used: -// All inputs use `EuiFieldText` which means all form values will be treated as strings. -// This means we cast other formats like numbers coming from the transform config to strings, -// then revalidate them and cast them again to number before submitting a transform update. -// We do this so we have fine grained control over field validation and the option to -// cast to special values like `null` for disabling `docs_per_second`. -import { - frequencyValidator, - integerAboveZeroValidator, - transformSettingsNumberOfRetriesValidator, - transformSettingsPageSearchSizeValidator, - retentionPolicyMaxAgeValidator, - stringValidator, - type Validator, -} from '../../../../common/validators'; - -// This custom hook uses nested reducers to provide a generic framework to manage form state -// and apply it to a final possibly nested configuration object suitable for passing on -// directly to an API call. For now this is only used for the transform edit form. -// Once we apply the functionality to other places, e.g. the transform creation wizard, -// the generic framework code in this file should be moved to a dedicated location. - -// The outer most level reducer defines a flat structure of names for form fields. -// This is a flat structure regardless of whether the final request object will be nested. -// For example, `destinationIndex` and `destinationIngestPipeline` will later be nested under `dest`. -export type EditTransformFormFields = - | 'description' - | 'destinationIndex' - | 'destinationIngestPipeline' - | 'docsPerSecond' - | 'frequency' - | 'maxPageSearchSize' - | 'numFailureRetries' - | 'retentionPolicyField' - | 'retentionPolicyMaxAge'; - -type EditTransformFlyoutFieldsState = Record; - -export interface FormField { - formFieldName: EditTransformFormFields; - configFieldName: string; - defaultValue: string; - dependsOn: EditTransformFormFields[]; - errorMessages: string[]; - isNullable: boolean; - isOptional: boolean; - section?: EditTransformFormSections; - validator: Validator; - value: string; - valueParser: (value: string) => any; -} - -// Defining these sections is only necessary for options where a reset/deletion of that part of the -// configuration is supported by the API. For example, this isn't suitable to use with `dest` since -// this overall part of the configuration is not optional. However, `retention_policy` is optional, -// so we need to support to recognize this based on the form state and be able to reset it by -// created a request body containing `{ retention_policy: null }`. -type EditTransformFormSections = 'retentionPolicy'; - -export interface FormSection { - formSectionName: EditTransformFormSections; - configFieldName: string; - defaultEnabled: boolean; - enabled: boolean; -} - -type EditTransformFlyoutSectionsState = Record; - -// The reducers and utility functions in this file provide the following features: -// - getDefaultState() -// Sets up the initial form state. It supports overrides to apply a pre-existing configuration. -// The implementation of this function is the only one that's specifically required to define -// the features of the transform edit form. All other functions are generic and could be reused -// in the future for other forms. -// -// - formReducerFactory() / formFieldReducer() -// These nested reducers take care of updating and validating the form state. -// -// - applyFormStateToTransformConfig() (iterates over getUpdateValue()) -// Once a user hits the update button, these functions take care of extracting the information -// necessary to create the update request. They take into account whether a field needs to -// be included at all in the request (for example, if it hadn't been changed). -// The code is also able to identify relationships/dependencies between form fields. -// For example, if the `pipeline` field was changed, it's necessary to make the `index` -// field part of the request, otherwise the update would fail. - -export const initializeField = ( - formFieldName: EditTransformFormFields, - configFieldName: string, - config: TransformConfigUnion, - overloads?: Partial -): FormField => { - const defaultValue = overloads?.defaultValue !== undefined ? overloads.defaultValue : ''; - const rawValue = getNestedProperty(config, configFieldName, undefined); - const value = rawValue !== null && rawValue !== undefined ? rawValue.toString() : ''; - - return { - formFieldName, - configFieldName, - defaultValue, - dependsOn: [], - errorMessages: [], - isNullable: false, - isOptional: true, - validator: stringValidator, - value, - valueParser: (v) => v, - ...(overloads !== undefined ? { ...overloads } : {}), - }; -}; - -export const initializeSection = ( - formSectionName: EditTransformFormSections, - configFieldName: string, - config: TransformConfigUnion, - overloads?: Partial -): FormSection => { - const defaultEnabled = overloads?.defaultEnabled ?? false; - const rawEnabled = getNestedProperty(config, configFieldName, undefined); - const enabled = rawEnabled !== undefined && rawEnabled !== null; - - return { - formSectionName, - configFieldName, - defaultEnabled, - enabled, - }; -}; - -export interface EditTransformFlyoutState { - apiErrorMessage?: string; - formFields: EditTransformFlyoutFieldsState; - formSections: EditTransformFlyoutSectionsState; - isFormTouched: boolean; - isFormValid: boolean; -} - -// Actions -interface ApiErrorAction { - name: 'api_error'; - payload: string | undefined; -} -interface FormFieldAction { - name: 'form_field'; - payload: { - field: EditTransformFormFields; - value: string; - }; -} -interface FormSectionAction { - name: 'form_section'; - payload: { - section: EditTransformFormSections; - enabled: boolean; - }; -} -type Action = ApiErrorAction | FormFieldAction | FormSectionAction; - -// Takes a value from form state and applies it to the structure -// of the expected final configuration request object. -// Considers options like if a value is nullable or optional. -const getUpdateValue = ( - attribute: EditTransformFormFields, - config: TransformConfigUnion, - formState: EditTransformFlyoutState, - enforceFormValue = false -) => { - const { formFields, formSections } = formState; - const formStateAttribute = formFields[attribute]; - const fallbackValue = formStateAttribute.isNullable ? null : formStateAttribute.defaultValue; - - const enabledBasedOnSection = - formStateAttribute.section !== undefined - ? formSections[formStateAttribute.section].enabled - : true; - - const formValue = - formStateAttribute.value !== '' - ? formStateAttribute.valueParser(formStateAttribute.value) - : fallbackValue; - - const configValue = getNestedProperty(config, formStateAttribute.configFieldName, fallbackValue); - - // only get depending values if we're not already in a call to get depending values. - const dependsOnConfig: PostTransformsUpdateRequestSchema = - enforceFormValue === false - ? formStateAttribute.dependsOn.reduce((_dependsOnConfig, dependsOnField) => { - return merge( - { ..._dependsOnConfig }, - getUpdateValue(dependsOnField, config, formState, true) - ); - }, {}) - : {}; - - if ( - formValue === formStateAttribute.defaultValue && - formValue === configValue && - formStateAttribute.isOptional - ) { - return {}; - } - - // If the resettable section the form field belongs to is disabled, - // the whole section will be set to `null` to do the actual reset. - if (formStateAttribute.section !== undefined && !enabledBasedOnSection) { - return setNestedProperty( - dependsOnConfig, - formSections[formStateAttribute.section].configFieldName, - null - ); - } - - return enabledBasedOnSection && (formValue !== configValue || enforceFormValue) - ? setNestedProperty( - dependsOnConfig, - formStateAttribute.configFieldName, - formValue === '' && formStateAttribute.isOptional ? undefined : formValue - ) - : {}; -}; - -// Takes in the form configuration and returns a -// request object suitable to be sent to the -// transform update API endpoint. -export const applyFormStateToTransformConfig = ( - config: TransformConfigUnion, - formState: EditTransformFlyoutState -): PostTransformsUpdateRequestSchema => - // Iterates over all form fields and only if necessary applies them to - // the request object used for updating the transform. - (Object.keys(formState.formFields) as EditTransformFormFields[]).reduce( - (updateConfig, field) => merge({ ...updateConfig }, getUpdateValue(field, config, formState)), - {} - ); - -// Takes in a transform configuration and returns -// the default state to populate the form. -export const getDefaultState = (config: TransformConfigUnion): EditTransformFlyoutState => ({ - formFields: { - // top level attributes - description: initializeField('description', 'description', config), - frequency: initializeField('frequency', 'frequency', config, { - defaultValue: DEFAULT_TRANSFORM_FREQUENCY, - validator: frequencyValidator, - }), - - // dest.* - destinationIndex: initializeField('destinationIndex', 'dest.index', config, { - dependsOn: ['destinationIngestPipeline'], - isOptional: false, - }), - destinationIngestPipeline: initializeField( - 'destinationIngestPipeline', - 'dest.pipeline', - config, - { - dependsOn: ['destinationIndex'], - isOptional: true, - } - ), - - // settings.* - docsPerSecond: initializeField('docsPerSecond', 'settings.docs_per_second', config, { - isNullable: true, - isOptional: true, - validator: integerAboveZeroValidator, - valueParser: (v) => (v === '' ? null : +v), - }), - maxPageSearchSize: initializeField( - 'maxPageSearchSize', - 'settings.max_page_search_size', - config, - { - defaultValue: `${DEFAULT_TRANSFORM_SETTINGS_MAX_PAGE_SEARCH_SIZE}`, - isNullable: true, - isOptional: true, - validator: transformSettingsPageSearchSizeValidator, - valueParser: (v) => +v, - } - ), - numFailureRetries: initializeField( - 'numFailureRetries', - 'settings.num_failure_retries', - config, - { - defaultValue: undefined, - isNullable: true, - isOptional: true, - validator: transformSettingsNumberOfRetriesValidator, - valueParser: (v) => +v, - } - ), - - // retention_policy.* - retentionPolicyField: initializeField( - 'retentionPolicyField', - 'retention_policy.time.field', - config, - { - dependsOn: ['retentionPolicyMaxAge'], - isNullable: false, - isOptional: true, - section: 'retentionPolicy', - } - ), - retentionPolicyMaxAge: initializeField( - 'retentionPolicyMaxAge', - 'retention_policy.time.max_age', - config, - { - dependsOn: ['retentionPolicyField'], - isNullable: false, - isOptional: true, - section: 'retentionPolicy', - validator: retentionPolicyMaxAgeValidator, - } - ), - }, - formSections: { - retentionPolicy: initializeSection('retentionPolicy', 'retention_policy', config), - }, - isFormTouched: false, - isFormValid: true, -}); - -// Checks each form field for error messages to return -// if the overall form is valid or not. -const isFormValid = (fieldsState: EditTransformFlyoutFieldsState) => - (Object.keys(fieldsState) as EditTransformFormFields[]).reduce( - (p, c) => p && fieldsState[c].errorMessages.length === 0, - true - ); - -// Updates a form field with its new value, -// runs validation and populates -// `errorMessages` if any errors occur. -const formFieldReducer = (state: FormField, value: string): FormField => { - return { - ...state, - errorMessages: - state.isOptional && typeof value === 'string' && value.length === 0 - ? [] - : state.validator(value, state.isOptional), - value, - }; -}; - -const formSectionReducer = (state: FormSection, enabled: boolean): FormSection => { - return { - ...state, - enabled, - }; -}; - -const getFieldValues = (fields: EditTransformFlyoutFieldsState) => - Object.values(fields).map((f) => f.value); -const getSectionValues = (sections: EditTransformFlyoutSectionsState) => - Object.values(sections).map((s) => s.enabled); - -// Main form reducer triggers -// - `formFieldReducer` to update the actions field -// - compares the most recent state against the original one to update `isFormTouched` -// - sets `isFormValid` to have a flag if any of the form fields contains an error. -export const formReducerFactory = (config: TransformConfigUnion) => { - const defaultState = getDefaultState(config); - const defaultFieldValues = getFieldValues(defaultState.formFields); - const defaultSectionValues = getSectionValues(defaultState.formSections); - - return (state: EditTransformFlyoutState, action: Action): EditTransformFlyoutState => { - const formFields = - action.name === 'form_field' - ? { - ...state.formFields, - [action.payload.field]: formFieldReducer( - state.formFields[action.payload.field], - action.payload.value - ), - } - : state.formFields; - - const formSections = - action.name === 'form_section' - ? { - ...state.formSections, - [action.payload.section]: formSectionReducer( - state.formSections[action.payload.section], - action.payload.enabled - ), - } - : state.formSections; - - return { - ...state, - apiErrorMessage: action.name === 'api_error' ? action.payload : state.apiErrorMessage, - formFields, - formSections, - isFormTouched: - !isEqual(defaultFieldValues, getFieldValues(formFields)) || - !isEqual(defaultSectionValues, getSectionValues(formSections)), - isFormValid: isFormValid(formFields), - }; - }; -}; - -interface EditTransformFlyoutOptions { - config: TransformConfigUnion; - dataViewId?: string; -} - -const useEditTransformFlyoutInternal = ({ config, dataViewId }: EditTransformFlyoutOptions) => { - const [formState, dispatch] = useReducer(formReducerFactory(config), getDefaultState(config)); - - const actions = useMemo( - () => ({ - apiError: (payload: ApiErrorAction['payload']) => dispatch({ name: 'api_error', payload }), - formField: (payload: FormFieldAction['payload']) => - dispatch({ - name: 'form_field', - payload, - }), - formSection: (payload: FormSectionAction['payload']) => - dispatch({ name: 'form_section', payload }), - }), - [] - ); - - const requestConfig = useMemo( - () => applyFormStateToTransformConfig(config, formState), - [config, formState] - ); - - const isUpdateButtonDisabled = useMemo( - () => !formState.isFormValid || !formState.isFormTouched, - [formState.isFormValid, formState.isFormTouched] - ); - - return { config, dataViewId, formState, actions, requestConfig, isUpdateButtonDisabled }; -}; - -// wrap hook with the constate factory to create context provider and custom hooks based on selectors -const [EditTransformFlyoutProvider, ...editTransformHooks] = constate( - useEditTransformFlyoutInternal, - (d) => d.config, - (d) => d.dataViewId, - (d) => d.actions, - (d) => d.formState.apiErrorMessage, - (d) => d.formState.formSections, - (d) => d.formState.formFields.description, - (d) => d.formState.formFields.destinationIndex, - (d) => d.formState.formFields.docsPerSecond, - (d) => d.formState.formFields.frequency, - (d) => d.formState.formFields.destinationIngestPipeline, - (d) => d.formState.formFields.maxPageSearchSize, - (d) => d.formState.formFields.numFailureRetries, - (d) => d.formState.formFields.retentionPolicyField, - (d) => d.formState.formFields.retentionPolicyMaxAge, - (d) => d.requestConfig, - (d) => d.isUpdateButtonDisabled -); - -export enum EDIT_TRANSFORM_HOOK_SELECTORS { - config, - dataViewId, - actions, - apiErrorMessage, - stateFormSection, - description, - destinationIndex, - docsPerSecond, - frequency, - destinationIngestPipeline, - maxPageSearchSize, - numFailureRetries, - retentionPolicyField, - retentionPolicyMaxAge, - requestConfig, - isUpdateButtonDisabled, -} - -export type EditTransformHookTextInputSelectors = Extract< - keyof typeof EDIT_TRANSFORM_HOOK_SELECTORS, - EditTransformFormFields ->; - -type EditTransformHookSelectors = keyof typeof EDIT_TRANSFORM_HOOK_SELECTORS; -type EditTransformHooks = typeof editTransformHooks; - -export const useEditTransformFlyout = (hookKey: K) => { - return editTransformHooks[EDIT_TRANSFORM_HOOK_SELECTORS[hookKey]]() as ReturnType< - EditTransformHooks[typeof EDIT_TRANSFORM_HOOK_SELECTORS[K]] - >; -}; - -export { EditTransformFlyoutProvider }; diff --git a/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/use_actions.tsx b/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/use_actions.tsx index b46628aeb4eff..31d39b12c4a36 100644 --- a/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/use_actions.tsx +++ b/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/use_actions.tsx @@ -15,7 +15,7 @@ import { TransformListRow } from '../../../../common'; import { useCloneAction } from '../action_clone'; import { useDeleteAction, DeleteActionModal } from '../action_delete'; import { useDiscoverAction } from '../action_discover'; -import { EditTransformFlyout } from '../edit_transform_flyout'; +import { EditTransformFlyout } from '../../../edit_transform'; import { useEditAction } from '../action_edit'; import { useResetAction, ResetActionModal } from '../action_reset'; import { useScheduleNowAction } from '../action_schedule_now';