From 6dd6c998184e5be310af3ba31d2fe0cb36651593 Mon Sep 17 00:00:00 2001 From: Jean-Louis Leysens Date: Wed, 3 Feb 2021 12:14:00 +0100 Subject: [PATCH] [ILM] Surfacing policy error state (#89701) * added policy top-level callout for error state * added form errors context. errors are sorted by their field path into phases * added data test subject attributes and prevent setErrors from getting called if component is not mounted * update copy * refactored errors context and optimised setting of context value. Also added test for various form error notifications across the collapsed phases * add test for non-phase specific policy validation error * Remove unused import * refactor how errors are listened to, use the new "onError" callback Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../edit_policy/edit_policy.helpers.tsx | 10 +- .../edit_policy/edit_policy.test.ts | 129 ++++++++++++++++++ .../toggle_field_with_described_form_row.tsx | 2 +- .../edit_policy/components/form_errors.tsx | 37 ----- .../components/form_errors_callout.tsx | 45 ++++++ .../sections/edit_policy/components/index.ts | 2 +- .../phases/cold_phase/cold_phase.tsx | 1 + .../phases/delete_phase/delete_phase.tsx | 4 +- .../components/phases/hot_phase/hot_phase.tsx | 6 +- .../components/phases/phase/index.ts | 7 + .../components/phases/{ => phase}/phase.tsx | 28 ++-- .../phases/phase/phase_error_indicator.tsx | 37 +++++ .../components/data_tier_allocation.tsx | 6 +- .../components/node_allocation.tsx | 6 +- .../phases/shared_fields/forcemerge_field.tsx | 4 +- .../shared_fields/index_priority_field.tsx | 9 +- .../min_age_field/min_age_field.tsx | 6 +- .../phases/shared_fields/replicas_field.tsx | 5 +- .../searchable_snapshot_field.tsx | 5 +- .../phases/shared_fields/shrink_field.tsx | 3 +- .../shared_fields/snapshot_policies_field.tsx | 4 +- .../sections/edit_policy/edit_policy.tsx | 15 +- .../form/components/enhanced_use_field.tsx | 73 ++++++++++ .../edit_policy/form/components/form.tsx | 5 +- .../edit_policy/form/components/index.ts | 2 + .../edit_policy/form/form_errors_context.tsx | 116 ++++++++++++++++ .../sections/edit_policy/form/index.ts | 4 +- .../public/shared_imports.ts | 4 +- 28 files changed, 497 insertions(+), 78 deletions(-) delete mode 100644 x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/form_errors.tsx create mode 100644 x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/form_errors_callout.tsx create mode 100644 x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/phase/index.ts rename x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/{ => phase}/phase.tsx (79%) create mode 100644 x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/phase/phase_error_indicator.tsx create mode 100644 x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/form/components/enhanced_use_field.tsx create mode 100644 x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/form/form_errors_context.tsx diff --git a/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/edit_policy/edit_policy.helpers.tsx b/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/edit_policy/edit_policy.helpers.tsx index d9256ec916ec..5d5d8a85163c 100644 --- a/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/edit_policy/edit_policy.helpers.tsx +++ b/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/edit_policy/edit_policy.helpers.tsx @@ -197,7 +197,9 @@ export const setup = async (arg?: { appServicesContext: Partial async (value: string) => { - await createFormToggleAction(`${phase}-setReplicasSwitch`)(true); + if (!exists(`${phase}-selectedReplicaCount`)) { + await createFormToggleAction(`${phase}-setReplicasSwitch`)(true); + } await createFormSetValueAction(`${phase}-selectedReplicaCount`)(value); }; @@ -248,8 +250,11 @@ export const setup = async (arg?: { appServicesContext: Partial exists('policyFormErrorsCallout'), timeline: { hasRolloverIndicator: () => exists('timelineHotPhaseRolloverToolTip'), hasHotPhase: () => exists('ilmTimelineHotPhase'), @@ -263,6 +268,7 @@ export const setup = async (arg?: { appServicesContext: Partial exists('phaseErrorIndicator-hot'), ...createForceMergeActions('hot'), ...createIndexPriorityActions('hot'), ...createShrinkActions('hot'), @@ -276,6 +282,7 @@ export const setup = async (arg?: { appServicesContext: Partial exists('phaseErrorIndicator-warm'), ...createShrinkActions('warm'), ...createForceMergeActions('warm'), setReadonly: setReadonly('warm'), @@ -290,6 +297,7 @@ export const setup = async (arg?: { appServicesContext: Partial exists('phaseErrorIndicator-cold'), ...createIndexPriorityActions('cold'), ...createSearchableSnapshotActions('cold'), }, diff --git a/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/edit_policy/edit_policy.test.ts b/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/edit_policy/edit_policy.test.ts index 05793a4bed58..9cff3953c2e1 100644 --- a/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/edit_policy/edit_policy.test.ts +++ b/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/edit_policy/edit_policy.test.ts @@ -29,6 +29,7 @@ window.scrollTo = jest.fn(); describe('', () => { let testBed: EditPolicyTestBed; const { server, httpRequestsMockHelpers } = setupEnvironment(); + afterAll(() => { server.restore(); }); @@ -852,4 +853,132 @@ describe('', () => { expect(actions.timeline.hasRolloverIndicator()).toBe(false); }); }); + + describe('policy error notifications', () => { + beforeAll(() => { + jest.useFakeTimers(); + }); + + afterAll(() => { + jest.useRealTimers(); + }); + beforeEach(async () => { + httpRequestsMockHelpers.setLoadPolicies([getDefaultHotPhasePolicy('my_policy')]); + httpRequestsMockHelpers.setListNodes({ + nodesByRoles: {}, + nodesByAttributes: { test: ['123'] }, + isUsingDeprecatedDataRoleConfig: false, + }); + httpRequestsMockHelpers.setLoadSnapshotPolicies([]); + + await act(async () => { + testBed = await setup(); + }); + + const { component } = testBed; + component.update(); + }); + + // For new we rely on a setTimeout to ensure that error messages have time to populate + // the form object before we look at the form object. See: + // x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/form/form_errors_context.tsx + // for where this logic lives. + const runTimers = () => { + const { component } = testBed; + act(() => { + jest.runAllTimers(); + }); + component.update(); + }; + + test('shows phase error indicators correctly', async () => { + // This test simulates a user configuring a policy phase by phase. The flow is the following: + // 0. Start with policy with no validation issues present + // 1. Configure hot, introducing a validation error + // 2. Configure warm, introducing a validation error + // 3. Configure cold, introducing a validation error + // 4. Fix validation error in hot + // 5. Fix validation error in warm + // 6. Fix validation error in cold + // We assert against each of these progressive states. + + const { actions } = testBed; + + // 0. No validation issues + expect(actions.hasGlobalErrorCallout()).toBe(false); + expect(actions.hot.hasErrorIndicator()).toBe(false); + expect(actions.warm.hasErrorIndicator()).toBe(false); + expect(actions.cold.hasErrorIndicator()).toBe(false); + + // 1. Hot phase validation issue + await actions.hot.toggleForceMerge(true); + await actions.hot.setForcemergeSegmentsCount('-22'); + runTimers(); + expect(actions.hasGlobalErrorCallout()).toBe(true); + expect(actions.hot.hasErrorIndicator()).toBe(true); + expect(actions.warm.hasErrorIndicator()).toBe(false); + expect(actions.cold.hasErrorIndicator()).toBe(false); + + // 2. Warm phase validation issue + await actions.warm.enable(true); + await actions.warm.toggleForceMerge(true); + await actions.warm.setForcemergeSegmentsCount('-22'); + await runTimers(); + expect(actions.hasGlobalErrorCallout()).toBe(true); + expect(actions.hot.hasErrorIndicator()).toBe(true); + expect(actions.warm.hasErrorIndicator()).toBe(true); + expect(actions.cold.hasErrorIndicator()).toBe(false); + + // 3. Cold phase validation issue + await actions.cold.enable(true); + await actions.cold.setReplicas('-33'); + await runTimers(); + expect(actions.hasGlobalErrorCallout()).toBe(true); + expect(actions.hot.hasErrorIndicator()).toBe(true); + expect(actions.warm.hasErrorIndicator()).toBe(true); + expect(actions.cold.hasErrorIndicator()).toBe(true); + + // 4. Fix validation issue in hot + await actions.hot.setForcemergeSegmentsCount('1'); + await runTimers(); + expect(actions.hasGlobalErrorCallout()).toBe(true); + expect(actions.hot.hasErrorIndicator()).toBe(false); + expect(actions.warm.hasErrorIndicator()).toBe(true); + expect(actions.cold.hasErrorIndicator()).toBe(true); + + // 5. Fix validation issue in warm + await actions.warm.setForcemergeSegmentsCount('1'); + await runTimers(); + expect(actions.hasGlobalErrorCallout()).toBe(true); + expect(actions.hot.hasErrorIndicator()).toBe(false); + expect(actions.warm.hasErrorIndicator()).toBe(false); + expect(actions.cold.hasErrorIndicator()).toBe(true); + + // 6. Fix validation issue in cold + await actions.cold.setReplicas('1'); + await runTimers(); + expect(actions.hasGlobalErrorCallout()).toBe(false); + expect(actions.hot.hasErrorIndicator()).toBe(false); + expect(actions.warm.hasErrorIndicator()).toBe(false); + expect(actions.cold.hasErrorIndicator()).toBe(false); + }); + + test('global error callout should show if there are any form errors', async () => { + const { actions } = testBed; + + expect(actions.hasGlobalErrorCallout()).toBe(false); + expect(actions.hot.hasErrorIndicator()).toBe(false); + expect(actions.warm.hasErrorIndicator()).toBe(false); + expect(actions.cold.hasErrorIndicator()).toBe(false); + + await actions.saveAsNewPolicy(true); + await actions.setPolicyName(''); + await runTimers(); + + expect(actions.hasGlobalErrorCallout()).toBe(true); + expect(actions.hot.hasErrorIndicator()).toBe(false); + expect(actions.warm.hasErrorIndicator()).toBe(false); + expect(actions.cold.hasErrorIndicator()).toBe(false); + }); + }); }); diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/described_form_row/toggle_field_with_described_form_row.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/described_form_row/toggle_field_with_described_form_row.tsx index 779dbe47914a..cddcd92a0f72 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/described_form_row/toggle_field_with_described_form_row.tsx +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/described_form_row/toggle_field_with_described_form_row.tsx @@ -5,7 +5,7 @@ */ import React, { FunctionComponent } from 'react'; -import { UseField } from '../../../../../shared_imports'; +import { UseField } from '../../form'; import { DescribedFormRow, diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/form_errors.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/form_errors.tsx deleted file mode 100644 index ed7ca6041767..000000000000 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/form_errors.tsx +++ /dev/null @@ -1,37 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import React, { cloneElement, Children, Fragment, ReactElement } from 'react'; -import { EuiFormRow, EuiFormRowProps } from '@elastic/eui'; - -type Props = EuiFormRowProps & { - isShowingErrors: boolean; - errors?: string | string[] | null; -}; - -export const ErrableFormRow: React.FunctionComponent = ({ - isShowingErrors, - errors, - children, - ...rest -}) => { - const _errors = errors ? (Array.isArray(errors) ? errors : [errors]) : undefined; - return ( - 0)} - error={errors} - {...rest} - > - - {Children.map(children, (child) => - cloneElement(child as ReactElement, { - isInvalid: errors && isShowingErrors && errors.length > 0, - }) - )} - - - ); -}; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/form_errors_callout.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/form_errors_callout.tsx new file mode 100644 index 000000000000..4e4adc6530f3 --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/form_errors_callout.tsx @@ -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; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { FunctionComponent } from 'react'; +import { i18n } from '@kbn/i18n'; +import { EuiCallOut, EuiSpacer } from '@elastic/eui'; + +import { useFormErrorsContext } from '../form'; + +const i18nTexts = { + callout: { + title: i18n.translate('xpack.indexLifecycleMgmt.policyErrorCalloutTitle', { + defaultMessage: 'This policy contains errors', + }), + body: i18n.translate('xpack.indexLifecycleMgmt.policyErrorCalloutDescription', { + defaultMessage: 'Please fix all errors before saving the policy.', + }), + }, +}; + +export const FormErrorsCallout: FunctionComponent = () => { + const { + errors: { hasErrors }, + } = useFormErrorsContext(); + + if (!hasErrors) { + return null; + } + + return ( + <> + + {i18nTexts.callout.body} + + + + ); +}; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/index.ts b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/index.ts index 960b632d70bd..c384ef7531bb 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/index.ts +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/index.ts @@ -5,7 +5,6 @@ */ export { ActiveBadge } from './active_badge'; -export { ErrableFormRow } from './form_errors'; export { LearnMoreLink } from './learn_more_link'; export { OptionalLabel } from './optional_label'; export { PolicyJsonFlyout } from './policy_json_flyout'; @@ -13,5 +12,6 @@ export { DescribedFormRow, ToggleFieldWithDescribedFormRow } from './described_f export { FieldLoadingError } from './field_loading_error'; export { ActiveHighlight } from './active_highlight'; export { Timeline } from './timeline'; +export { FormErrorsCallout } from './form_errors_callout'; export * from './phases'; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/cold_phase/cold_phase.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/cold_phase/cold_phase.tsx index 976f584ef4d3..405cdd5bcde7 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/cold_phase/cold_phase.tsx +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/cold_phase/cold_phase.tsx @@ -23,6 +23,7 @@ import { IndexPriorityField, ReplicasField, } from '../shared_fields'; + import { Phase } from '../phase'; const i18nTexts = { diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/delete_phase/delete_phase.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/delete_phase/delete_phase.tsx index 5c43bb413eb5..a3196ddcf023 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/delete_phase/delete_phase.tsx +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/delete_phase/delete_phase.tsx @@ -9,7 +9,9 @@ import { get } from 'lodash'; import { FormattedMessage } from '@kbn/i18n/react'; import { EuiDescribedFormGroup, EuiTextColor, EuiFormRow } from '@elastic/eui'; -import { useFormData, UseField, ToggleField } from '../../../../../../shared_imports'; +import { useFormData, ToggleField } from '../../../../../../shared_imports'; + +import { UseField } from '../../../form'; import { ActiveBadge, LearnMoreLink, OptionalLabel } from '../../index'; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/hot_phase/hot_phase.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/hot_phase/hot_phase.tsx index 02de47f8c56e..70740ddb81f8 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/hot_phase/hot_phase.tsx +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/hot_phase/hot_phase.tsx @@ -19,11 +19,11 @@ import { EuiIcon, } from '@elastic/eui'; -import { useFormData, UseField, SelectField, NumericField } from '../../../../../../shared_imports'; +import { useFormData, SelectField, NumericField } from '../../../../../../shared_imports'; import { i18nTexts } from '../../../i18n_texts'; -import { ROLLOVER_EMPTY_VALIDATION, useConfigurationIssues } from '../../../form'; +import { ROLLOVER_EMPTY_VALIDATION, useConfigurationIssues, UseField } from '../../../form'; import { useEditPolicyContext } from '../../../edit_policy_context'; @@ -38,8 +38,8 @@ import { ReadonlyField, ShrinkField, } from '../shared_fields'; - import { Phase } from '../phase'; + import { maxSizeStoredUnits, maxAgeUnits } from './constants'; export const HotPhase: FunctionComponent = () => { diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/phase/index.ts b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/phase/index.ts new file mode 100644 index 000000000000..d8c6fec557dc --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/phase/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { Phase } from './phase'; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/phase.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/phase/phase.tsx similarity index 79% rename from x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/phase.tsx rename to x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/phase/phase.tsx index 6de18f1c1d3c..829c75bdced6 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/phase.tsx +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/phase/phase.tsx @@ -18,11 +18,14 @@ import { import { get } from 'lodash'; import { FormattedMessage } from '@kbn/i18n/react'; -import { ToggleField, UseField, useFormData } from '../../../../../shared_imports'; -import { i18nTexts } from '../../i18n_texts'; +import { ToggleField, useFormData } from '../../../../../../shared_imports'; +import { i18nTexts } from '../../../i18n_texts'; -import { ActiveHighlight } from '../active_highlight'; -import { MinAgeField } from './shared_fields'; +import { UseField } from '../../../form'; +import { ActiveHighlight } from '../../active_highlight'; +import { MinAgeField } from '../shared_fields'; + +import { PhaseErrorIndicator } from './phase_error_indicator'; interface Props { phase: 'hot' | 'warm' | 'cold'; @@ -63,9 +66,16 @@ export const Phase: FunctionComponent = ({ children, phase }) => { )} - -

{i18nTexts.editPolicy.titles[phase]}

-
+ + + +

{i18nTexts.editPolicy.titles[phase]}

+
+
+ + + +
@@ -74,7 +84,7 @@ export const Phase: FunctionComponent = ({ children, phase }) => { @@ -102,7 +112,7 @@ export const Phase: FunctionComponent = ({ children, phase }) => { )} - + {i18nTexts.editPolicy.descriptions[phase]} diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/phase/phase_error_indicator.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/phase/phase_error_indicator.tsx new file mode 100644 index 000000000000..f156ddcaee96 --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/phase/phase_error_indicator.tsx @@ -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; + * you may not use this file except in compliance with the Elastic License. + */ +import { i18n } from '@kbn/i18n'; +import React, { FunctionComponent, memo } from 'react'; +import { EuiIconTip } from '@elastic/eui'; + +import { useFormErrorsContext } from '../../../form'; + +interface Props { + phase: 'hot' | 'warm' | 'cold'; +} + +const i18nTexts = { + toolTipContent: i18n.translate('xpack.indexLifecycleMgmt.phaseErrorIcon.tooltipDescription', { + defaultMessage: 'This phase contains errors.', + }), +}; + +/** + * This component hooks into the form state and updates whenever new form data is inputted. + */ +export const PhaseErrorIndicator: FunctionComponent = memo(({ phase }) => { + const { errors } = useFormErrorsContext(); + + if (Object.keys(errors[phase]).length) { + return ( +
+ +
+ ); + } + + return null; +}); diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/data_tier_allocation_field/components/data_tier_allocation.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/data_tier_allocation_field/components/data_tier_allocation.tsx index 8af5314c16b1..3dc0d6d45a9b 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/data_tier_allocation_field/components/data_tier_allocation.tsx +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/data_tier_allocation_field/components/data_tier_allocation.tsx @@ -4,16 +4,18 @@ * you may not use this file except in compliance with the Elastic License. */ +import { i18n } from '@kbn/i18n'; import React, { FunctionComponent } from 'react'; import { get } from 'lodash'; -import { i18n } from '@kbn/i18n'; import { EuiText, EuiSpacer, EuiSuperSelectOption } from '@elastic/eui'; -import { UseField, SuperSelectField, useFormData } from '../../../../../../../../shared_imports'; +import { SuperSelectField, useFormData } from '../../../../../../../../shared_imports'; import { PhaseWithAllocation } from '../../../../../../../../../common/types'; import { DataTierAllocationType } from '../../../../../types'; +import { UseField } from '../../../../../form'; + import { NodeAllocation } from './node_allocation'; import { SharedProps } from './types'; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/data_tier_allocation_field/components/node_allocation.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/data_tier_allocation_field/components/node_allocation.tsx index 9f60337166f4..371cb95f8091 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/data_tier_allocation_field/components/node_allocation.tsx +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/data_tier_allocation_field/components/node_allocation.tsx @@ -4,13 +4,15 @@ * you may not use this file except in compliance with the Elastic License. */ +import { i18n } from '@kbn/i18n'; import React, { useState, FunctionComponent } from 'react'; import { get } from 'lodash'; import { FormattedMessage } from '@kbn/i18n/react'; -import { i18n } from '@kbn/i18n'; import { EuiButtonEmpty, EuiText, EuiSpacer } from '@elastic/eui'; -import { UseField, SelectField, useFormData } from '../../../../../../../../shared_imports'; +import { SelectField, useFormData } from '../../../../../../../../shared_imports'; + +import { UseField } from '../../../../../form'; import { LearnMoreLink } from '../../../../learn_more_link'; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/forcemerge_field.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/forcemerge_field.tsx index 8d6807c90dae..cd6e9f83eb13 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/forcemerge_field.tsx +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/forcemerge_field.tsx @@ -7,12 +7,14 @@ import React, { useMemo } from 'react'; import { FormattedMessage } from '@kbn/i18n/react'; -import { UseField, CheckBoxField, NumericField } from '../../../../../../shared_imports'; +import { CheckBoxField, NumericField } from '../../../../../../shared_imports'; import { i18nTexts } from '../../../i18n_texts'; import { useEditPolicyContext } from '../../../edit_policy_context'; +import { UseField } from '../../../form'; + import { LearnMoreLink, DescribedFormRow } from '../../'; interface Props { diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/index_priority_field.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/index_priority_field.tsx index 570033812c24..79b4b49cbb65 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/index_priority_field.tsx +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/index_priority_field.tsx @@ -4,14 +4,17 @@ * you may not use this file except in compliance with the Elastic License. */ +import { i18n } from '@kbn/i18n'; import React, { FunctionComponent, useMemo } from 'react'; import { FormattedMessage } from '@kbn/i18n/react'; -import { i18n } from '@kbn/i18n'; import { EuiSpacer, EuiTextColor } from '@elastic/eui'; -import { UseField, NumericField } from '../../../../../../shared_imports'; -import { LearnMoreLink, DescribedFormRow } from '../..'; +import { NumericField } from '../../../../../../shared_imports'; + import { useEditPolicyContext } from '../../../edit_policy_context'; +import { UseField } from '../../../form'; + +import { LearnMoreLink, DescribedFormRow } from '../..'; interface Props { phase: 'hot' | 'warm' | 'cold'; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/min_age_field/min_age_field.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/min_age_field/min_age_field.tsx index 8a84b7fa0e76..9937ae2a0b5b 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/min_age_field/min_age_field.tsx +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/min_age_field/min_age_field.tsx @@ -3,8 +3,8 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ -import React, { FunctionComponent } from 'react'; import { i18n } from '@kbn/i18n'; +import React, { FunctionComponent } from 'react'; import { FormattedMessage } from '@kbn/i18n/react'; import { @@ -17,7 +17,9 @@ import { EuiText, } from '@elastic/eui'; -import { UseField, getFieldValidityAndErrorMessage } from '../../../../../../../shared_imports'; +import { getFieldValidityAndErrorMessage } from '../../../../../../../shared_imports'; + +import { UseField } from '../../../../form'; import { getUnitsAriaLabelForPhase, getTimingLabelForPhase } from './util'; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/replicas_field.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/replicas_field.tsx index 6d8e019ff8a0..189fdc2fdf6d 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/replicas_field.tsx +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/replicas_field.tsx @@ -7,8 +7,11 @@ import React, { FunctionComponent } from 'react'; import { i18n } from '@kbn/i18n'; -import { UseField, NumericField } from '../../../../../../shared_imports'; +import { NumericField } from '../../../../../../shared_imports'; + import { useEditPolicyContext } from '../../../edit_policy_context'; +import { UseField } from '../../../form'; + import { DescribedFormRow } from '../../described_form_row'; interface Props { diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/searchable_snapshot_field/searchable_snapshot_field.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/searchable_snapshot_field/searchable_snapshot_field.tsx index 5fa192158fb3..0050fe1d87e9 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/searchable_snapshot_field/searchable_snapshot_field.tsx +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/searchable_snapshot_field/searchable_snapshot_field.tsx @@ -4,8 +4,8 @@ * you may not use this file except in compliance with the Elastic License. */ -import { get } from 'lodash'; import { i18n } from '@kbn/i18n'; +import { get } from 'lodash'; import React, { FunctionComponent, useState, useEffect } from 'react'; import { FormattedMessage } from '@kbn/i18n/react'; import { @@ -17,7 +17,6 @@ import { } from '@elastic/eui'; import { - UseField, ComboBoxField, useKibana, fieldValidators, @@ -25,7 +24,7 @@ import { } from '../../../../../../../shared_imports'; import { useEditPolicyContext } from '../../../../edit_policy_context'; -import { useConfigurationIssues } from '../../../../form'; +import { useConfigurationIssues, UseField } from '../../../../form'; import { i18nTexts } from '../../../../i18n_texts'; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/shrink_field.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/shrink_field.tsx index da200e9e68d1..33f6fc83b184 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/shrink_field.tsx +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/shrink_field.tsx @@ -7,9 +7,10 @@ import { FormattedMessage } from '@kbn/i18n/react'; import { EuiFlexGroup, EuiFlexItem, EuiSpacer, EuiTextColor } from '@elastic/eui'; import React, { FunctionComponent } from 'react'; -import { UseField, NumericField } from '../../../../../../shared_imports'; +import { NumericField } from '../../../../../../shared_imports'; import { useEditPolicyContext } from '../../../edit_policy_context'; +import { UseField } from '../../../form'; import { i18nTexts } from '../../../i18n_texts'; import { LearnMoreLink, DescribedFormRow } from '../../'; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/snapshot_policies_field.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/snapshot_policies_field.tsx index 05f12f6ba61c..ece362e5ae01 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/snapshot_policies_field.tsx +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/snapshot_policies_field.tsx @@ -11,9 +11,11 @@ import { i18n } from '@kbn/i18n'; import { EuiCallOut, EuiComboBoxOptionOption, EuiLink, EuiSpacer } from '@elastic/eui'; -import { UseField, ComboBoxField, useFormData } from '../../../../../../shared_imports'; +import { ComboBoxField, useFormData } from '../../../../../../shared_imports'; import { useLoadSnapshotPolicies } from '../../../../../services/api'; + import { useEditPolicyContext } from '../../../edit_policy_context'; +import { UseField } from '../../../form'; import { FieldLoadingError } from '../../'; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/edit_policy.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/edit_policy.tsx index b1cf41773de3..420abdf02013 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/edit_policy.tsx +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/edit_policy.tsx @@ -4,15 +4,13 @@ * you may not use this file except in compliance with the Elastic License. */ +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; import React, { Fragment, useEffect, useMemo, useState } from 'react'; import { get } from 'lodash'; import { RouteComponentProps } from 'react-router-dom'; -import { FormattedMessage } from '@kbn/i18n/react'; - -import { i18n } from '@kbn/i18n'; - import { EuiButton, EuiButtonEmpty, @@ -31,9 +29,12 @@ import { EuiTitle, } from '@elastic/eui'; -import { TextField, UseField, useForm, useFormData } from '../../../shared_imports'; +import { TextField, useForm, useFormData } from '../../../shared_imports'; import { toasts } from '../../services/notification'; +import { createDocLink } from '../../services/documentation'; + +import { UseField } from './form'; import { savePolicy } from './save_policy'; @@ -44,6 +45,7 @@ import { PolicyJsonFlyout, WarmPhase, Timeline, + FormErrorsCallout, } from './components'; import { createPolicyNameValidations, createSerializer, deserializer, Form, schema } from './form'; @@ -51,7 +53,6 @@ import { createPolicyNameValidations, createSerializer, deserializer, Form, sche import { useEditPolicyContext } from './edit_policy_context'; import { FormInternal } from './types'; -import { createDocLink } from '../../services/documentation'; export interface Props { history: RouteComponentProps['history']; @@ -253,6 +254,8 @@ export const EditPolicy: React.FunctionComponent = ({ history }) => { + + diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/form/components/enhanced_use_field.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/form/components/enhanced_use_field.tsx new file mode 100644 index 000000000000..332a8c2ba369 --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/form/components/enhanced_use_field.tsx @@ -0,0 +1,73 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { useEffect, useRef, useMemo, useCallback } from 'react'; + +// We wrap this component for edit policy so we do not export it from the "shared_imports" dir to avoid +// accidentally using the non-enhanced version. +import { UseField } from '../../../../../../../../../src/plugins/es_ui_shared/static/forms/hook_form_lib'; + +import { Phases } from '../../../../../../common/types'; + +import { UseFieldProps, FormData } from '../../../../../shared_imports'; + +import { useFormErrorsContext } from '../form_errors_context'; + +const isXPhaseField = (phase: keyof Phases) => (fieldPath: string): boolean => + fieldPath.startsWith(`phases.${phase}`) || fieldPath.startsWith(`_meta.${phase}`); + +const isHotPhaseField = isXPhaseField('hot'); +const isWarmPhaseField = isXPhaseField('warm'); +const isColdPhaseField = isXPhaseField('cold'); +const isDeletePhaseField = isXPhaseField('delete'); + +const determineFieldPhase = (fieldPath: string): keyof Phases | 'other' => { + if (isHotPhaseField(fieldPath)) { + return 'hot'; + } + if (isWarmPhaseField(fieldPath)) { + return 'warm'; + } + if (isColdPhaseField(fieldPath)) { + return 'cold'; + } + if (isDeletePhaseField(fieldPath)) { + return 'delete'; + } + return 'other'; +}; + +export const EnhancedUseField = ( + props: UseFieldProps +): React.ReactElement | null => { + const { path } = props; + const isMounted = useRef(false); + const phase = useMemo(() => determineFieldPhase(path), [path]); + const { addError, clearError } = useFormErrorsContext(); + + const onError = useCallback( + (errors: string[] | null) => { + if (!isMounted.current) { + return; + } + if (errors) { + addError(phase, path, errors); + } else { + clearError(phase, path); + } + }, + [phase, path, addError, clearError] + ); + + useEffect(() => { + isMounted.current = true; + return () => { + isMounted.current = false; + }; + }, []); + + return ; +}; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/form/components/form.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/form/components/form.tsx index 2b3411e394a9..cad029478c49 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/form/components/form.tsx +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/form/components/form.tsx @@ -9,6 +9,7 @@ import React, { FunctionComponent } from 'react'; import { Form as LibForm, FormHook } from '../../../../../shared_imports'; import { ConfigurationIssuesProvider } from '../configuration_issues_context'; +import { FormErrorsProvider } from '../form_errors_context'; interface Props { form: FormHook; @@ -16,6 +17,8 @@ interface Props { export const Form: FunctionComponent = ({ form, children }) => ( - {children} + + {children} + ); diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/form/components/index.ts b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/form/components/index.ts index 15d8d4ed272e..06cfa5daf599 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/form/components/index.ts +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/form/components/index.ts @@ -5,3 +5,5 @@ */ export { Form } from './form'; + +export { EnhancedUseField } from './enhanced_use_field'; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/form/form_errors_context.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/form/form_errors_context.tsx new file mode 100644 index 000000000000..e4c01e35476f --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/form/form_errors_context.tsx @@ -0,0 +1,116 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { createContext, useContext, FunctionComponent, useState, useCallback } from 'react'; + +import { Phases as _Phases } from '../../../../../common/types'; + +import { useFormContext } from '../../../../shared_imports'; + +import { FormInternal } from '../types'; + +type Phases = keyof _Phases; + +type PhasesAndOther = Phases | 'other'; + +interface ErrorGroup { + [fieldPath: string]: string[]; +} + +interface Errors { + hasErrors: boolean; + hot: ErrorGroup; + warm: ErrorGroup; + cold: ErrorGroup; + delete: ErrorGroup; + /** + * Errors that are not specific to a phase should go here. + */ + other: ErrorGroup; +} + +interface ContextValue { + errors: Errors; + addError(phase: PhasesAndOther, fieldPath: string, errorMessages: string[]): void; + clearError(phase: PhasesAndOther, fieldPath: string): void; +} + +const FormErrorsContext = createContext(null as any); + +const createEmptyErrors = (): Errors => ({ + hasErrors: false, + hot: {}, + warm: {}, + cold: {}, + delete: {}, + other: {}, +}); + +export const FormErrorsProvider: FunctionComponent = ({ children }) => { + const [errors, setErrors] = useState(createEmptyErrors); + const form = useFormContext(); + + const addError: ContextValue['addError'] = useCallback( + (phase, fieldPath, errorMessages) => { + setErrors((previousErrors) => ({ + ...previousErrors, + hasErrors: true, + [phase]: { + ...previousErrors[phase], + [fieldPath]: errorMessages, + }, + })); + }, + [setErrors] + ); + + const clearError: ContextValue['clearError'] = useCallback( + (phase, fieldPath) => { + if (form.getErrors().length) { + setErrors((previousErrors) => { + const { + [phase]: { [fieldPath]: fieldErrorToOmit, ...restOfPhaseErrors }, + ...otherPhases + } = previousErrors; + + const hasErrors = + Object.keys(restOfPhaseErrors).length === 0 && + Object.keys(otherPhases).some((phaseErrors) => !!Object.keys(phaseErrors).length); + + return { + ...previousErrors, + hasErrors, + [phase]: restOfPhaseErrors, + }; + }); + } else { + setErrors(createEmptyErrors); + } + }, + [form, setErrors] + ); + + return ( + + {children} + + ); +}; + +export const useFormErrorsContext = () => { + const ctx = useContext(FormErrorsContext); + if (!ctx) { + throw new Error('useFormErrorsContext can only be used inside of FormErrorsProvider'); + } + + return ctx; +}; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/form/index.ts b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/form/index.ts index 66fe498cbac8..e8a63295b4b0 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/form/index.ts +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/form/index.ts @@ -12,9 +12,11 @@ export { schema } from './schema'; export * from './validations'; -export { Form } from './components'; +export { Form, EnhancedUseField as UseField } from './components'; export { ConfigurationIssuesProvider, useConfigurationIssues, } from './configuration_issues_context'; + +export { FormErrorsProvider, useFormErrorsContext } from './form_errors_context'; diff --git a/x-pack/plugins/index_lifecycle_management/public/shared_imports.ts b/x-pack/plugins/index_lifecycle_management/public/shared_imports.ts index fdb25dec6f1f..daaf1fa6ffd6 100644 --- a/x-pack/plugins/index_lifecycle_management/public/shared_imports.ts +++ b/x-pack/plugins/index_lifecycle_management/public/shared_imports.ts @@ -12,7 +12,9 @@ export { useFormData, Form, FormHook, - UseField, + FieldHook, + FormData, + Props as UseFieldProps, FieldConfig, OnFormUpdateArg, ValidationFunc,