[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:
parent
f4ebdf3a79
commit
6dd6c99818
|
@ -197,7 +197,9 @@ export const setup = async (arg?: { appServicesContext: Partial<AppServicesConte
|
|||
createFormSetValueAction(`${phase}-selectedNodeAttrs`);
|
||||
|
||||
const setReplicas = (phase: Phases) => async (value: string) => {
|
||||
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'),
|
||||
},
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -5,7 +5,7 @@
|
|||
*/
|
||||
import React, { FunctionComponent } from 'react';
|
||||
|
||||
import { UseField } from '../../../../../shared_imports';
|
||||
import { UseField } from '../../form';
|
||||
|
||||
import {
|
||||
DescribedFormRow,
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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 />
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -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';
|
||||
|
|
|
@ -23,6 +23,7 @@ import {
|
|||
IndexPriorityField,
|
||||
ReplicasField,
|
||||
} from '../shared_fields';
|
||||
|
||||
import { Phase } from '../phase';
|
||||
|
||||
const i18nTexts = {
|
||||
|
|
|
@ -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';
|
||||
|
||||
|
|
|
@ -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 = () => {
|
||||
|
|
|
@ -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';
|
|
@ -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,10 +66,17 @@ export const Phase: FunctionComponent<Props> = ({ children, phase }) => {
|
|||
</EuiFlexItem>
|
||||
)}
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiTitle size={'s'}>
|
||||
<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>
|
||||
{enabled && (
|
||||
|
@ -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>
|
||||
|
|
@ -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;
|
||||
});
|
|
@ -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';
|
||||
|
||||
|
|
|
@ -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';
|
||||
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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';
|
||||
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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';
|
||||
|
||||
|
|
|
@ -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 '../../';
|
||||
|
|
|
@ -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 '../../';
|
||||
|
||||
|
|
|
@ -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">
|
||||
|
|
|
@ -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} />;
|
||||
};
|
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -5,3 +5,5 @@
|
|||
*/
|
||||
|
||||
export { Form } from './form';
|
||||
|
||||
export { EnhancedUseField } from './enhanced_use_field';
|
||||
|
|
|
@ -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;
|
||||
};
|
|
@ -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';
|
||||
|
|
|
@ -12,7 +12,9 @@ export {
|
|||
useFormData,
|
||||
Form,
|
||||
FormHook,
|
||||
UseField,
|
||||
FieldHook,
|
||||
FormData,
|
||||
Props as UseFieldProps,
|
||||
FieldConfig,
|
||||
OnFormUpdateArg,
|
||||
ValidationFunc,
|
||||
|
|
Loading…
Reference in a new issue