[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>
This commit is contained in:
Jean-Louis Leysens 2021-02-03 12:14:00 +01:00 committed by GitHub
parent f4ebdf3a79
commit 6dd6c99818
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
28 changed files with 497 additions and 78 deletions

View file

@ -197,7 +197,9 @@ export const setup = async (arg?: { appServicesContext: Partial<AppServicesConte
createFormSetValueAction(`${phase}-selectedNodeAttrs`);
const setReplicas = (phase: Phases) => 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<AppServicesConte
return {
...testBed,
actions: {
saveAsNewPolicy: createFormToggleAction('saveAsNewSwitch'),
setPolicyName: createFormSetValueAction('policyNameField'),
setWaitForSnapshotPolicy,
savePolicy,
hasGlobalErrorCallout: () => exists('policyFormErrorsCallout'),
timeline: {
hasRolloverIndicator: () => exists('timelineHotPhaseRolloverToolTip'),
hasHotPhase: () => exists('ilmTimelineHotPhase'),
@ -263,6 +268,7 @@ export const setup = async (arg?: { appServicesContext: Partial<AppServicesConte
setMaxAge,
toggleRollover,
toggleDefaultRollover,
hasErrorIndicator: () => exists('phaseErrorIndicator-hot'),
...createForceMergeActions('hot'),
...createIndexPriorityActions('hot'),
...createShrinkActions('hot'),
@ -276,6 +282,7 @@ export const setup = async (arg?: { appServicesContext: Partial<AppServicesConte
setDataAllocation: setDataAllocation('warm'),
setSelectedNodeAttribute: setSelectedNodeAttribute('warm'),
setReplicas: setReplicas('warm'),
hasErrorIndicator: () => exists('phaseErrorIndicator-warm'),
...createShrinkActions('warm'),
...createForceMergeActions('warm'),
setReadonly: setReadonly('warm'),
@ -290,6 +297,7 @@ export const setup = async (arg?: { appServicesContext: Partial<AppServicesConte
setReplicas: setReplicas('cold'),
setFreeze,
freezeExists,
hasErrorIndicator: () => exists('phaseErrorIndicator-cold'),
...createIndexPriorityActions('cold'),
...createSearchableSnapshotActions('cold'),
},

View file

@ -29,6 +29,7 @@ window.scrollTo = jest.fn();
describe('<EditPolicy />', () => {
let testBed: EditPolicyTestBed;
const { server, httpRequestsMockHelpers } = setupEnvironment();
afterAll(() => {
server.restore();
});
@ -852,4 +853,132 @@ describe('<EditPolicy />', () => {
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);
});
});
});

View file

@ -5,7 +5,7 @@
*/
import React, { FunctionComponent } from 'react';
import { UseField } from '../../../../../shared_imports';
import { UseField } from '../../form';
import {
DescribedFormRow,

View file

@ -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<Props> = ({
isShowingErrors,
errors,
children,
...rest
}) => {
const _errors = errors ? (Array.isArray(errors) ? errors : [errors]) : undefined;
return (
<EuiFormRow
isInvalid={isShowingErrors || (_errors && _errors.length > 0)}
error={errors}
{...rest}
>
<Fragment>
{Children.map(children, (child) =>
cloneElement(child as ReactElement, {
isInvalid: errors && isShowingErrors && errors.length > 0,
})
)}
</Fragment>
</EuiFormRow>
);
};

View file

@ -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 (
<>
<EuiCallOut
data-test-subj="policyFormErrorsCallout"
color="danger"
title={i18nTexts.callout.title}
>
{i18nTexts.callout.body}
</EuiCallOut>
<EuiSpacer />
</>
);
};

View file

@ -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';

View file

@ -23,6 +23,7 @@ import {
IndexPriorityField,
ReplicasField,
} from '../shared_fields';
import { Phase } from '../phase';
const i18nTexts = {

View file

@ -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';

View file

@ -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 = () => {

View file

@ -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';

View file

@ -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<Props> = ({ children, phase }) => {
</EuiFlexItem>
)}
<EuiFlexItem grow={false}>
<EuiTitle size={'s'}>
<h2>{i18nTexts.editPolicy.titles[phase]}</h2>
</EuiTitle>
<EuiFlexGroup alignItems="center" gutterSize="s" responsive={false}>
<EuiFlexItem>
<EuiTitle size="s">
<h2>{i18nTexts.editPolicy.titles[phase]}</h2>
</EuiTitle>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<PhaseErrorIndicator phase={phase} />
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
@ -74,7 +84,7 @@ export const Phase: FunctionComponent<Props> = ({ children, phase }) => {
<EuiFlexGroup
justifyContent="spaceBetween"
alignItems="center"
gutterSize={'xs'}
gutterSize="xs"
wrap
>
<EuiFlexItem grow={true}>
@ -102,7 +112,7 @@ export const Phase: FunctionComponent<Props> = ({ children, phase }) => {
)}
</EuiFlexGroup>
<EuiSpacer />
<EuiText color="subdued" size={'s'} style={{ maxWidth: '50%' }}>
<EuiText color="subdued" size="s" style={{ maxWidth: '50%' }}>
{i18nTexts.editPolicy.descriptions[phase]}
</EuiText>

View file

@ -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<Props> = memo(({ phase }) => {
const { errors } = useFormErrorsContext();
if (Object.keys(errors[phase]).length) {
return (
<div data-test-subj={`phaseErrorIndicator-${phase}`}>
<EuiIconTip type="alert" color="danger" content={i18nTexts.toolTipContent} />
</div>
);
}
return null;
});

View file

@ -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';

View file

@ -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';

View file

@ -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 {

View file

@ -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';

View file

@ -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';

View file

@ -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 {

View file

@ -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';

View file

@ -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 '../../';

View file

@ -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 '../../';

View file

@ -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<Props> = ({ history }) => {
<EuiHorizontalRule />
<FormErrorsCallout />
<EuiFlexGroup justifyContent="spaceBetween">
<EuiFlexItem grow={false}>
<EuiButtonEmpty onClick={togglePolicyJsonFlyout} data-test-subj="requestButton">

View file

@ -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 = <T, F = FormData, I = T>(
props: UseFieldProps<T, F, I>
): React.ReactElement<any, any> | null => {
const { path } = props;
const isMounted = useRef<boolean>(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 <UseField {...props} onError={onError} />;
};

View file

@ -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<Props> = ({ form, children }) => (
<LibForm form={form}>
<ConfigurationIssuesProvider>{children}</ConfigurationIssuesProvider>
<ConfigurationIssuesProvider>
<FormErrorsProvider>{children}</FormErrorsProvider>
</ConfigurationIssuesProvider>
</LibForm>
);

View file

@ -5,3 +5,5 @@
*/
export { Form } from './form';
export { EnhancedUseField } from './enhanced_use_field';

View file

@ -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<ContextValue>(null as any);
const createEmptyErrors = (): Errors => ({
hasErrors: false,
hot: {},
warm: {},
cold: {},
delete: {},
other: {},
});
export const FormErrorsProvider: FunctionComponent = ({ children }) => {
const [errors, setErrors] = useState<Errors>(createEmptyErrors);
const form = useFormContext<FormInternal>();
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 (
<FormErrorsContext.Provider
value={{
errors,
addError,
clearError,
}}
>
{children}
</FormErrorsContext.Provider>
);
};
export const useFormErrorsContext = () => {
const ctx = useContext(FormErrorsContext);
if (!ctx) {
throw new Error('useFormErrorsContext can only be used inside of FormErrorsProvider');
}
return ctx;
};

View file

@ -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';

View file

@ -12,7 +12,9 @@ export {
useFormData,
Form,
FormHook,
UseField,
FieldHook,
FormData,
Props as UseFieldProps,
FieldConfig,
OnFormUpdateArg,
ValidationFunc,