Merge pull request #1697 from merico-dev/jc-1606-add-edit-connection-validation-onfocus

`feat` `v0.10.0` connection inputs: add validate-on-focus support
diff --git a/config-ui/src/components/validation/InputValidationError.jsx b/config-ui/src/components/validation/InputValidationError.jsx
index 3eb8f57..0b90b9b 100644
--- a/config-ui/src/components/validation/InputValidationError.jsx
+++ b/config-ui/src/components/validation/InputValidationError.jsx
@@ -1,4 +1,4 @@
-import React from 'react'
+import React, { useEffect, useState, useCallback } from 'react'
 import {
   Colors,
   Icon,
@@ -9,7 +9,71 @@
 } from '@blueprintjs/core'
 
 const InputValidationError = (props) => {
-  const { error, position = Position.TOP } = props
+  const {
+    error,
+    position = Position.TOP,
+    // eslint-disable-next-line no-unused-vars
+    validateOnFocus = false,
+    elementRef, onError = () => {},
+    onSuccess = () => {},
+    interactionKind = PopoverInteractionKind.HOVER_TARGET_ONLY
+  } = props
+
+  const [elementIsFocused, setElementIsFocused] = useState(false)
+  // eslint-disable-next-line no-unused-vars
+  const [inputElement, setInputElement] = useState(null)
+
+  const handleElementFocus = useCallback((isFocused, ref) => {
+    setElementIsFocused(isFocused)
+    if (error) {
+      elementRef?.current.parentElement.classList.remove('valid-field')
+      elementRef?.current.parentElement.classList.add('invalid-field')
+    } else {
+      elementRef?.current.parentElement.classList.remove('invalid-field')
+      elementRef?.current.parentElement.classList.add('valid-field')
+    }
+  }, [elementRef, error])
+
+  const handleElementBlur = useCallback((isFocused, ref) => {
+    setElementIsFocused(isFocused)
+    if (!error) {
+      elementRef?.current.parentElement.classList.remove('invalid-field')
+    }
+  }, [elementRef, error])
+
+  useEffect(() => {
+    const iRef = elementRef?.current
+    if (iRef) {
+      setInputElement(iRef)
+      iRef.addEventListener('focus', (e) => handleElementFocus(true, iRef), true)
+      iRef.addEventListener('keyup', (e) => handleElementFocus(true, iRef), true)
+      iRef.addEventListener('blur', (e) => handleElementBlur(false, iRef), true)
+    } else {
+      setInputElement(null)
+    }
+
+    return () => {
+      iRef?.removeEventListener('focus', setElementIsFocused, true)
+      iRef?.removeEventListener('keyup', setElementIsFocused, true)
+      iRef?.removeEventListener('blur', setElementIsFocused, true)
+      setInputElement(null)
+    }
+  }, [elementRef, handleElementBlur, handleElementFocus])
+
+  useEffect(() => {
+    if (error && validateOnFocus && elementIsFocused) {
+      onError(elementRef?.current?.id ? elementRef?.current?.id : null)
+    } else if (error && !validateOnFocus) {
+      onError(elementRef?.current?.id ? elementRef?.current?.id : null)
+    } else {
+      onSuccess()
+    }
+  }, [error, onError, onSuccess, elementIsFocused, validateOnFocus, elementRef])
+
+  useEffect(() => {
+
+  }, [validateOnFocus])
+
   return error
     ? (
       <div className='inline-input-error' style={{ outline: 'none', cursor: 'pointer', margin: '5px 5px 3px 5px' }}>
@@ -18,9 +82,17 @@
           usePortal={true}
           openOnTargetFocus={true}
           intent={Intent.WARNING}
-          interactionKind={PopoverInteractionKind.HOVER_TARGET_ONLY}
+          interactionKind={interactionKind}
+          enforceFocus={false}
+          // autoFocus={false}
         >
-          <Icon icon='warning-sign' size={12} color={Colors.RED5} style={{ outline: 'none' }} />
+          <Icon
+            icon='warning-sign'
+            size={12}
+            color={(validateOnFocus && elementIsFocused) || (error && !validateOnFocus) ? Colors.RED5 : Colors.GRAY5}
+            style={{ outline: 'none' }}
+            onClick={(e) => e.stopPropagation()}
+          />
           <div style={{ outline: 'none', padding: '5px', borderTop: `2px solid ${Colors.RED5}` }}>{error}</div>
         </Popover>
       </div>
diff --git a/config-ui/src/pages/configure/connections/ConnectionForm.jsx b/config-ui/src/pages/configure/connections/ConnectionForm.jsx
index 2ec5961..f16fdc8 100644
--- a/config-ui/src/pages/configure/connections/ConnectionForm.jsx
+++ b/config-ui/src/pages/configure/connections/ConnectionForm.jsx
@@ -1,4 +1,4 @@
-import React, { useEffect, useState, useCallback } from 'react'
+import React, { useEffect, useState, useCallback, useRef } from 'react'
 import {
   Button, Colors,
   FormGroup, InputGroup, Label,
@@ -54,9 +54,15 @@
     placeholders
   } = props
 
+  const connectionNameRef = useRef()
+  const connectionEndpointRef = useRef()
+  const connectionTokenRef = useRef()
+
   // const [isValidForm, setIsValidForm] = useState(true)
   const [allowedAuthTypes, setAllowedAuthTypes] = useState(['token', 'plain'])
   const [showTokenCreator, setShowTokenCreator] = useState(false)
+  const [stateErrored, setStateErrored] = useState(false)
+
   const getConnectionStatusIcon = () => {
     let statusIcon = <Icon icon='full-circle' size='10' color={Colors.RED3} />
     switch (testStatus) {
@@ -96,6 +102,10 @@
     return validationErrors.find(e => e.includes(fieldId))
   }
 
+  const activateErrorStates = (elementId) => {
+    setStateErrored(elementId || false)
+  }
+
   useEffect(() => {
     if (!allowedAuthTypes.includes(authType)) {
       console.log('INVALID AUTH TYPE!')
@@ -184,17 +194,24 @@
             </Label>
             <InputGroup
               id='connection-name'
+              inputRef={connectionNameRef}
               disabled={isTesting || isSaving || isLocked}
               readOnly={[Providers.GITHUB, Providers.GITLAB, Providers.JENKINS].includes(activeProvider.id)}
               placeholder={placeholders ? placeholders.name : 'Enter Instance Name'}
               value={name}
               onChange={(e) => onNameChange(e.target.value)}
-              className={`input connection-name-input ${fieldHasError('Connection Source') ? 'invalid-field' : ''}`}
+              // className={`input connection-name-input ${fieldHasError('Connection Source') ? 'invalid-field' : ''}`}
+              // className='input connection-name-input'
+              className={`input connection-name-input ${stateErrored === 'connection-name' ? 'invalid-field' : ''}`}
               leftIcon={[Providers.GITHUB, Providers.GITLAB, Providers.JENKINS].includes(activeProvider.id) ? 'lock' : null}
               inline={true}
               rightElement={(
                 <InputValidationError
                   error={getFieldError('Connection Source')}
+                  elementRef={connectionNameRef}
+                  onError={activateErrorStates}
+                  onSuccess={() => setStateErrored(null)}
+                  validateOnFocus
                 />
               )}
               // fill
@@ -221,15 +238,20 @@
             </Label>
             <InputGroup
               id='connection-endpoint'
+              inputRef={connectionEndpointRef}
               disabled={isTesting || isSaving || isLocked}
               placeholder={placeholders ? placeholders.endpoint : 'Enter Endpoint URL'}
               value={endpointUrl}
               onChange={(e) => onEndpointChange(e.target.value)}
-              className={`input endpoint-url-input ${fieldHasError('Endpoint') ? 'invalid-field' : ''}`}
+              className={`input endpoint-url-input ${stateErrored === 'connection-endpoint' ? 'invalid-field' : ''}`}
               fill
               rightElement={(
                 <InputValidationError
                   error={getFieldError('Endpoint')}
+                  elementRef={connectionEndpointRef}
+                  onError={activateErrorStates}
+                  onSuccess={() => setStateErrored(null)}
+                  validateOnFocus
                 />
               )}
             />
@@ -257,16 +279,21 @@
               </Label>
               <InputGroup
                 id='connection-token'
+                inputRef={connectionTokenRef}
                 disabled={isTesting || isSaving || isLocked}
                 placeholder={placeholders ? placeholders.token : 'Enter Auth Token eg. EJrLG8DNeXADQcGOaaaX4B47'}
                 value={token}
                 onChange={(e) => onTokenChange(e.target.value)}
-                className={`input auth-input ${fieldHasError('Auth') ? 'invalid-field' : ''}`}
+                className={`input auth-input ${stateErrored === 'connection-token' ? 'invalid-field' : ''}`}
                 fill
                 required
                 rightElement={(
                   <InputValidationError
                     error={getFieldError('Auth')}
+                    elementRef={connectionTokenRef}
+                    onError={activateErrorStates}
+                    onSuccess={() => setStateErrored(null)}
+                    validateOnFocus
                   />
                 )}
               />