diff --git a/x-pack/plugins/security_solution/public/management/pages/blocklist/view/components/blocklist_form.test.tsx b/x-pack/plugins/security_solution/public/management/pages/blocklist/view/components/blocklist_form.test.tsx index 21798594641d8..1c2ed0fd028b9 100644 --- a/x-pack/plugins/security_solution/public/management/pages/blocklist/view/components/blocklist_form.test.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/blocklist/view/components/blocklist_form.test.tsx @@ -462,6 +462,18 @@ describe('blocklist form', () => { expect(screen.queryByText(ERRORS.INVALID_PATH)).toBeTruthy(); }); + it('should prevail duplicate value warning on lost focus', async () => { + const item = createItem({ + os_types: [OperatingSystem.WINDOWS], + entries: [createEntry('file.Ext.code_signature', ['valid', 'invalid'])], + }); + render(createProps({ item })); + await user.type(screen.getByRole('combobox'), 'invalid{enter}'); + expect(screen.queryByText(ERRORS.DUPLICATE_VALUE)).toBeTruthy(); + await user.click(screen.getByTestId('blocklist-form-os-select')); + expect(screen.queryByText(ERRORS.DUPLICATE_VALUE)).toBeTruthy(); + }); + it('should warn if single duplicate value entry', async () => { const hash = 'C3AB8FF13720E8AD9047DD39466B3C8974E592C2FA383D4A3960714CAEF0C4F2'; const item = createItem({ diff --git a/x-pack/plugins/security_solution/public/management/pages/blocklist/view/components/blocklist_form.tsx b/x-pack/plugins/security_solution/public/management/pages/blocklist/view/components/blocklist_form.tsx index 2163a0f156833..fcdcc016c3f37 100644 --- a/x-pack/plugins/security_solution/public/management/pages/blocklist/view/components/blocklist_form.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/blocklist/view/components/blocklist_form.tsx @@ -114,10 +114,8 @@ function isValid(itemValidation: ItemValidation): boolean { // eslint-disable-next-line react/display-name export const BlockListForm = memo( ({ item, policies, policiesIsLoading, onChange, mode }) => { - const [visited, setVisited] = useState<{ name: boolean; value: boolean }>({ - name: false, - value: false, - }); + const [nameVisited, setNameVisited] = useState(false); + const [valueVisited, setValueVisited] = useState({ value: false }); // Use object to trigger re-render const warningsRef = useRef({ name: {}, value: {} }); const errorsRef = useRef({ name: {}, value: {} }); const [selectedPolicies, setSelectedPolicies] = useState([]); @@ -269,57 +267,75 @@ export const BlockListForm = memo( ); }, [displaySingleValueInput]); - const validateValues = useCallback((nextItem: ArtifactFormComponentProps['item']) => { - const os = ((nextItem.os_types ?? [])[0] as OperatingSystem) ?? OperatingSystem.WINDOWS; - const { - field = 'file.hash.*', - type = ListOperatorTypeEnum.MATCH_ANY, - value = [], - } = (nextItem.entries[0] ?? {}) as BlocklistEntry; - - // value can be a string when isOperator is selected - const values = Array.isArray(value) ? value : [value].filter(Boolean); - - const newValueWarnings: ItemValidationNodes = {}; - const newNameErrors: ItemValidationNodes = {}; - const newValueErrors: ItemValidationNodes = {}; - // error if name empty - if (!nextItem.name.trim()) { - newNameErrors.NAME_REQUIRED = createValidationMessage(ERRORS.NAME_REQUIRED); - } + const validateValues = useCallback( + (nextItem: ArtifactFormComponentProps['item'], cleanState = false) => { + const os = ((nextItem.os_types ?? [])[0] as OperatingSystem) ?? OperatingSystem.WINDOWS; + const { + field = 'file.hash.*', + type = ListOperatorTypeEnum.MATCH_ANY, + value = [], + } = (nextItem.entries[0] ?? {}) as BlocklistEntry; + + // value can be a string when isOperator is selected + const values = Array.isArray(value) ? value : [value].filter(Boolean); + + const newValueWarnings: ItemValidationNodes = cleanState + ? {} + : { ...warningsRef.current.value }; + const newNameErrors: ItemValidationNodes = cleanState ? {} : { ...errorsRef.current.name }; + const newValueErrors: ItemValidationNodes = cleanState + ? {} + : { ...errorsRef.current.value }; + + // error if name empty + if (!nextItem.name.trim()) { + newNameErrors.NAME_REQUIRED = createValidationMessage(ERRORS.NAME_REQUIRED); + } else { + delete newNameErrors.NAME_REQUIRED; + } - // error if no values - if (!values.length) { - newValueErrors.VALUE_REQUIRED = createValidationMessage(ERRORS.VALUE_REQUIRED); - } + // error if no values + if (!values.length) { + newValueErrors.VALUE_REQUIRED = createValidationMessage(ERRORS.VALUE_REQUIRED); + } else { + delete newValueErrors.VALUE_REQUIRED; + } - // error if invalid hash - if (field === 'file.hash.*' && values.some((v) => !isValidHash(v))) { - newValueErrors.INVALID_HASH = createValidationMessage(ERRORS.INVALID_HASH); - } + // error if invalid hash + if (field === 'file.hash.*' && values.some((v) => !isValidHash(v))) { + newValueErrors.INVALID_HASH = createValidationMessage(ERRORS.INVALID_HASH); + } else { + delete newValueErrors.INVALID_HASH; + } - const isInvalidPath = values.some((v) => !isPathValid({ os, field, type, value: v })); - // warn if invalid path - if (field !== 'file.hash.*' && isInvalidPath) { - newValueWarnings.INVALID_PATH = createValidationMessage(ERRORS.INVALID_PATH); - } - // warn if duplicates - if (values.length !== uniq(values).length) { - newValueWarnings.DUPLICATE_VALUES = createValidationMessage(ERRORS.DUPLICATE_VALUES); - } + const isInvalidPath = values.some((v) => !isPathValid({ os, field, type, value: v })); + // warn if invalid path + if (field !== 'file.hash.*' && isInvalidPath) { + newValueWarnings.INVALID_PATH = createValidationMessage(ERRORS.INVALID_PATH); + } else { + delete newValueWarnings.INVALID_PATH; + } + // warn if duplicates + if (values.length !== uniq(values).length) { + newValueWarnings.DUPLICATE_VALUES = createValidationMessage(ERRORS.DUPLICATE_VALUES); + } else { + delete newValueWarnings.DUPLICATE_VALUES; + } - warningsRef.current = { ...warningsRef.current, value: newValueWarnings }; - errorsRef.current = { name: newNameErrors, value: newValueErrors }; - }, []); + warningsRef.current = { ...warningsRef.current, value: newValueWarnings }; + errorsRef.current = { name: newNameErrors, value: newValueErrors }; + }, + [] + ); const handleOnNameBlur = useCallback(() => { validateValues(item); - setVisited((prevVisited) => ({ ...prevVisited, name: true })); + setNameVisited(true); }, [item, validateValues]); const handleOnValueBlur = useCallback(() => { validateValues(item); - setVisited((prevVisited) => ({ ...prevVisited, value: true })); + setValueVisited({ value: true }); }, [item, validateValues]); const handleOnNameChange = useCallback( @@ -463,7 +479,7 @@ export const BlockListForm = memo( }; // trigger re-render without modifying item - setVisited((prevVisited) => ({ ...prevVisited })); + setValueVisited((prevState) => ({ ...prevState })); }, [blocklistEntry] ); @@ -495,7 +511,7 @@ export const BlockListForm = memo( entries: [{ ...blocklistEntry, value }], } as ArtifactFormComponentProps['item']; - validateValues(nextItem); + validateValues(nextItem, true); onChange({ isValid: isValid(errorsRef.current), item: nextItem, @@ -518,7 +534,7 @@ export const BlockListForm = memo( validateValues(nextItem as ArtifactFormComponentProps['item']); nextItem.entries[0].value = uniq(nextItem.entries[0].value); - setVisited((prevVisited) => ({ ...prevVisited, value: true })); + setValueVisited({ value: true }); onChange({ isValid: isValid(errorsRef.current), item: nextItem as ArtifactFormComponentProps['item'], @@ -563,7 +579,7 @@ export const BlockListForm = memo( @@ -572,7 +588,7 @@ export const BlockListForm = memo( value={item.name} onChange={handleOnNameChange} onBlur={handleOnNameBlur} - required={visited.name} + required={nameVisited} maxLength={256} data-test-subj={getTestId('name-input')} fullWidth @@ -651,7 +667,7 @@ export const BlockListForm = memo(