Skip to content

Commit

Permalink
implement non blocking validation errors as warnings
Browse files Browse the repository at this point in the history
  • Loading branch information
maximpn committed Dec 2, 2024
1 parent 4784022 commit 631bedc
Show file tree
Hide file tree
Showing 29 changed files with 500 additions and 699 deletions.
17 changes: 12 additions & 5 deletions x-pack/plugins/security_solution/public/common/hooks/eql/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,11 +36,18 @@ interface Params {
signal?: AbortSignal;
}

export interface EqlResponseError {
code: EQL_ERROR_CODES;
messages?: string[];
error?: Error;
}
export type EqlResponseError =
| {
code:
| EQL_ERROR_CODES.INVALID_SYNTAX
| EQL_ERROR_CODES.INVALID_EQL
| EQL_ERROR_CODES.MISSING_DATA_SOURCE;
messages: string[];
}
| {
code: EQL_ERROR_CODES.FAILED_REQUEST;
error: Error;
};

export interface ValidateEqlResponse {
valid: boolean;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,49 +5,44 @@
* 2.0.
*/

import React from 'react';

import React, { memo } from 'react';
import { EuiConfirmModal, EuiSpacer, EuiText } from '@elastic/eui';

import * as i18n from './translations';

interface SaveWithErrorsModalProps {
interface ConfirmValidationErrorsModalProps {
errors: string[];
onCancel: () => void;
onConfirm: () => void;
}

const SaveWithErrorsModalComponent = ({
export const ConfirmValidationErrorsModal = memo(function ConfirmValidationErrorsModal({
errors,
onCancel,
onConfirm,
}: SaveWithErrorsModalProps) => {
}: ConfirmValidationErrorsModalProps): JSX.Element {
return (
<EuiConfirmModal
data-test-subj="save-with-errors-confirmation-modal"
title={i18n.SAVE_WITH_ERRORS_MODAL_TITLE}
onCancel={onCancel}
onConfirm={onConfirm}
cancelButtonText={i18n.SAVE_WITH_ERRORS_CANCEL_BUTTON}
confirmButtonText={i18n.SAVE_WITH_ERRORS_CONFIRM_BUTTON}
cancelButtonText={i18n.CANCEL}
confirmButtonText={i18n.CONFIRM}
defaultFocusedButton="confirm"
>
<>
{i18n.SAVE_WITH_ERRORS_MODAL_MESSAGE(errors.length)}
{i18n.SAVE_WITH_ERRORS_MESSAGE(errors.length)}
<EuiSpacer size="s" />
<ul>
{errors.map((validationError, idx) => {
{errors.map((error) => {
return (
<li key={idx}>
<EuiText>{validationError}</EuiText>
<li key={error}>
<EuiText>{error}</EuiText>
</li>
);
})}
</ul>
</>
</EuiConfirmModal>
);
};

export const SaveWithErrorsModal = React.memo(SaveWithErrorsModalComponent);
SaveWithErrorsModal.displayName = 'SaveWithErrorsModal';
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/

export * from './use_confirm_validation_errors_modal';
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/

import { i18n } from '@kbn/i18n';

export const SAVE_WITH_ERRORS_MODAL_TITLE = i18n.translate(
'xpack.securitySolution.detectionEngine.rules.upgradeRules.saveWithErrorsConfirmationModal.title',
{
defaultMessage: 'There are validation errors',
}
);

export const CANCEL = i18n.translate(
'xpack.securitySolution.detectionEngine.rules.upgradeRules.saveWithErrorsConfirmationModal.cancel',
{
defaultMessage: 'Cancel',
}
);

export const CONFIRM = i18n.translate(
'xpack.securitySolution.detectionEngine.rules.upgradeRules.saveWithErrorsConfirmationModal.confirm',
{
defaultMessage: 'Confirm',
}
);

export const SAVE_WITH_ERRORS_MESSAGE = (errorsCount: number) =>
i18n.translate('xpack.securitySolution.detectionEngine.createRule.saveWithErrorsModalMessage', {
defaultMessage:
'There are {errorsCount} validation {errorsCount, plural, one {error} other {errors}} which can lead to failed rule executions, save anyway?',
values: { errorsCount },
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/

import type { ReactNode } from 'react';
import React, { useCallback, useState, useMemo } from 'react';
import { useBoolean } from '@kbn/react-hooks';
import { useAsyncConfirmation } from '../../../detection_engine/rule_management_ui/components/rules_table/rules_table/use_async_confirmation';
import { ConfirmValidationErrorsModal } from './confirm_validation_errors_modal';

interface UseFieldConfirmValidationErrorsModalResult {
modal: ReactNode;
confirmValidationErrors: (errorMessages: string[]) => Promise<boolean>;
}

export function useConfirmValidationErrorsModal(): UseFieldConfirmValidationErrorsModalResult {
const [visible, { on: showModal, off: hideModal }] = useBoolean(false);
const [initModal, confirm, cancel] = useAsyncConfirmation({
onInit: showModal,
onFinish: hideModal,
});
const [errorsToConfirm, setErrorsToConfirm] = useState<string[]>([]);

const confirmValidationErrors = useCallback(
(errorMessages: string[]) => {
if (errorMessages.length === 0) {
return Promise.resolve(true);
}

setErrorsToConfirm(errorMessages);

return initModal();
},
[initModal, setErrorsToConfirm]
);

const modal = useMemo(
() =>
visible ? (
<ConfirmValidationErrorsModal
errors={errorsToConfirm}
onConfirm={confirm}
onCancel={cancel}
/>
) : null,
[visible, errorsToConfirm, confirm, cancel]
);

return {
modal,
confirmValidationErrors,
};
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/

import type { FieldHook, ValidationError } from '../../../shared_imports';

interface ExtractValidationResultsResult {
errors: ValidationError[];
warnings: ValidationError[];
}

export function extractValidationResults(
formFields: Readonly<FieldHook[]>,
warningValidationCodes: Readonly<string[]>
): ExtractValidationResultsResult {
const warningValidationCodesSet = new Set(warningValidationCodes);
const errors: ValidationError[] = [];
const warnings: ValidationError[] = [];

for (const field of formFields) {
for (const error of field.errors) {
const path = error.path ?? field.path;

if (!error.code || !warningValidationCodesSet.has(error.code)) {
errors.push({ ...error, path });
} else {
warnings.push({ ...error, path });
}
}
}

return {
errors,
warnings,
};
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/

import type { FormHook, FormData } from '../../../shared_imports';

export interface FormHookWithWarn<T extends FormData = FormData, I extends FormData = T>
extends FormHook<T, I> {
getWarnings(): string[];
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/

export type * from './form_hook_with_warn';
export * from './use_form_with_warn';
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/

import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { isEmpty } from 'lodash';
import type { FormHook, ValidationError } from '../../../shared_imports';
import { useForm, type FormConfig, type FormData } from '../../../shared_imports';
import type { FormHookWithWarn } from './form_hook_with_warn';
import { extractValidationResults } from './extract_validation_results';

interface SubmitHandlerWithWarnExtras {
errors: ValidationError[];
warnings: ValidationError[];
}

export type FormWithWarnSubmitHandler<T extends FormData = FormData> = (
formData: T,
isValid: boolean,
extras: SubmitHandlerWithWarnExtras
) => Promise<void>;

interface FormWithWarnConfig<T extends FormData = FormData, I extends FormData = T>
extends Omit<FormConfig<T, I>, 'onSubmit'> {
onSubmit?: FormWithWarnSubmitHandler<T>;
options: FormConfig['options'] & {
warningValidationCodes: Readonly<string[]>;
};
}

interface UseFormWithWarnReturn<T extends FormData = FormData, I extends FormData = T> {
form: FormHookWithWarn<T, I>;
}

export function useFormWithWarn<T extends FormData = FormData, I extends FormData = T>(
formConfig: FormWithWarnConfig<T, I>
): UseFormWithWarnReturn<T, I> {
const {
onSubmit,
options: { warningValidationCodes },
} = formConfig;
const { form } = useForm(formConfig as FormConfig<T, I>);
const { validate: originalValidate, getFormData, getFields } = form;

const errorsRef = useRef<ValidationError[]>([]);
const warningsRef = useRef<ValidationError[]>([]);
const [isSubmitted, setIsSubmitted] = useState(false);
const [isSubmitting, setSubmitting] = useState(false);
const [isValid, setIsValid] = useState<boolean>();
const isMounted = useRef(false);

const validate: FormHook<T, I>['validate'] = useCallback(async () => {
await originalValidate();

const validationResult = extractValidationResults(
Object.values(getFields()),
warningValidationCodes
);

errorsRef.current = validationResult.errors;
warningsRef.current = validationResult.warnings;

const isFormValid = isEmpty(errorsRef.current);

setIsValid(isFormValid);

return isFormValid;
}, [originalValidate, getFields, warningValidationCodes, errorsRef, warningsRef]);

const submit: FormHook<T, I>['submit'] = useCallback(
async (e) => {
if (e) {
e.preventDefault();
}

setIsSubmitted(true);
setSubmitting(true);

const isFormValid = await validate();
const formData = isFormValid ? getFormData() : ({} as T);

if (onSubmit) {
await onSubmit(formData, isFormValid, {
errors: errorsRef.current,
warnings: warningsRef.current,
});
}

if (isMounted.current) {
setSubmitting(false);
}

return { data: formData, isValid: isFormValid };
},
[validate, getFormData, onSubmit, errorsRef, warningsRef]
);

// Track form's mounted state
useEffect(() => {
isMounted.current = true;

return () => {
isMounted.current = false;
};
}, []);

return useMemo(
() => ({
form: {
...form,
isValid,
isSubmitted,
isSubmitting,
validate,
submit,
getErrors: () => errorsRef.current.map((x) => x.message),
getWarnings: () => warningsRef.current.map((x) => x.message),
},
}),
[form, validate, submit, isSubmitted, isSubmitting, isValid, errorsRef, warningsRef]
);
}
Loading

0 comments on commit 631bedc

Please sign in to comment.