mirror of
				https://github.com/lingble/twenty.git
				synced 2025-10-30 20:27:55 +00:00 
			
		
		
		
	fix: validate emails in record-fields (#7245)
fix: #7149 Introduced a minimal field validation framework for record-fields. Currently only shows errors for email field. <img width="350" alt="image" src="https://github.com/user-attachments/assets/1a1fa790-71a4-4764-a791-9878be3274f1"> <img width="347" alt="image" src="https://github.com/user-attachments/assets/e22d24f2-d1a7-4303-8c41-7aac3cde9ce8"> --------- Co-authored-by: sid0-0 <a@b.com> Co-authored-by: bosiraphael <raphael.bosi@gmail.com> Co-authored-by: Félix Malfait <felix@twenty.com>
This commit is contained in:
		| @@ -1,6 +1,7 @@ | |||||||
| import { useEmailsField } from '@/object-record/record-field/meta-types/hooks/useEmailsField'; | import { useEmailsField } from '@/object-record/record-field/meta-types/hooks/useEmailsField'; | ||||||
| import { EmailsFieldMenuItem } from '@/object-record/record-field/meta-types/input/components/EmailsFieldMenuItem'; | import { EmailsFieldMenuItem } from '@/object-record/record-field/meta-types/input/components/EmailsFieldMenuItem'; | ||||||
| import { useMemo } from 'react'; | import { emailSchema } from '@/object-record/record-field/validation-schemas/emailSchema'; | ||||||
|  | import { useCallback, useMemo } from 'react'; | ||||||
| import { isDefined } from 'twenty-ui'; | import { isDefined } from 'twenty-ui'; | ||||||
| import { FieldMetadataType } from '~/generated-metadata/graphql'; | import { FieldMetadataType } from '~/generated-metadata/graphql'; | ||||||
| import { MultiItemFieldInput } from './MultiItemFieldInput'; | import { MultiItemFieldInput } from './MultiItemFieldInput'; | ||||||
| @@ -29,6 +30,14 @@ export const EmailsFieldInput = ({ onCancel }: EmailsFieldInputProps) => { | |||||||
|     }); |     }); | ||||||
|   }; |   }; | ||||||
|  |  | ||||||
|  |   const validateInput = useCallback( | ||||||
|  |     (input: string) => ({ | ||||||
|  |       isValid: emailSchema.safeParse(input).success, | ||||||
|  |       errorMessage: '', | ||||||
|  |     }), | ||||||
|  |     [], | ||||||
|  |   ); | ||||||
|  |  | ||||||
|   const isPrimaryEmail = (index: number) => index === 0 && emails?.length > 1; |   const isPrimaryEmail = (index: number) => index === 0 && emails?.length > 1; | ||||||
|  |  | ||||||
|   return ( |   return ( | ||||||
| @@ -38,6 +47,7 @@ export const EmailsFieldInput = ({ onCancel }: EmailsFieldInputProps) => { | |||||||
|       onCancel={onCancel} |       onCancel={onCancel} | ||||||
|       placeholder="Email" |       placeholder="Email" | ||||||
|       fieldMetadataType={FieldMetadataType.Emails} |       fieldMetadataType={FieldMetadataType.Emails} | ||||||
|  |       validateInput={validateInput} | ||||||
|       renderItem={({ |       renderItem={({ | ||||||
|         value: email, |         value: email, | ||||||
|         index, |         index, | ||||||
|   | |||||||
| @@ -51,7 +51,10 @@ export const LinksFieldInput = ({ onCancel }: LinksFieldInputProps) => { | |||||||
|       onCancel={onCancel} |       onCancel={onCancel} | ||||||
|       placeholder="URL" |       placeholder="URL" | ||||||
|       fieldMetadataType={FieldMetadataType.Links} |       fieldMetadataType={FieldMetadataType.Links} | ||||||
|       validateInput={(input) => absoluteUrlSchema.safeParse(input).success} |       validateInput={(input) => ({ | ||||||
|  |         isValid: absoluteUrlSchema.safeParse(input).success, | ||||||
|  |         errorMessage: '', | ||||||
|  |       })} | ||||||
|       formatInput={(input) => ({ url: input, label: '' })} |       formatInput={(input) => ({ url: input, label: '' })} | ||||||
|       renderItem={({ |       renderItem={({ | ||||||
|         value: link, |         value: link, | ||||||
|   | |||||||
| @@ -30,7 +30,7 @@ type MultiItemFieldInputProps<T> = { | |||||||
|   onPersist: (updatedItems: T[]) => void; |   onPersist: (updatedItems: T[]) => void; | ||||||
|   onCancel?: () => void; |   onCancel?: () => void; | ||||||
|   placeholder: string; |   placeholder: string; | ||||||
|   validateInput?: (input: string) => boolean; |   validateInput?: (input: string) => { isValid: boolean; errorMessage: string }; | ||||||
|   formatInput?: (input: string) => T; |   formatInput?: (input: string) => T; | ||||||
|   renderItem: (props: { |   renderItem: (props: { | ||||||
|     value: T; |     value: T; | ||||||
| @@ -74,8 +74,21 @@ export const MultiItemFieldInput = <T,>({ | |||||||
|   const [isInputDisplayed, setIsInputDisplayed] = useState(false); |   const [isInputDisplayed, setIsInputDisplayed] = useState(false); | ||||||
|   const [inputValue, setInputValue] = useState(''); |   const [inputValue, setInputValue] = useState(''); | ||||||
|   const [itemToEditIndex, setItemToEditIndex] = useState(-1); |   const [itemToEditIndex, setItemToEditIndex] = useState(-1); | ||||||
|  |   const [errorData, setErrorData] = useState({ | ||||||
|  |     isValid: true, | ||||||
|  |     errorMessage: '', | ||||||
|  |   }); | ||||||
|   const isAddingNewItem = itemToEditIndex === -1; |   const isAddingNewItem = itemToEditIndex === -1; | ||||||
|  |  | ||||||
|  |   const handleOnChange = (value: string) => { | ||||||
|  |     setInputValue(value); | ||||||
|  |     if (!validateInput) return; | ||||||
|  |  | ||||||
|  |     if (errorData.isValid) { | ||||||
|  |       setErrorData(errorData); | ||||||
|  |     } | ||||||
|  |   }; | ||||||
|  |  | ||||||
|   const handleAddButtonClick = () => { |   const handleAddButtonClick = () => { | ||||||
|     setItemToEditIndex(-1); |     setItemToEditIndex(-1); | ||||||
|     setIsInputDisplayed(true); |     setIsInputDisplayed(true); | ||||||
| @@ -105,7 +118,13 @@ export const MultiItemFieldInput = <T,>({ | |||||||
|   }; |   }; | ||||||
|  |  | ||||||
|   const handleSubmitInput = () => { |   const handleSubmitInput = () => { | ||||||
|     if (validateInput !== undefined && !validateInput(inputValue)) return; |     if (validateInput !== undefined) { | ||||||
|  |       const validationData = validateInput(inputValue) ?? { isValid: true }; | ||||||
|  |       if (!validationData.isValid) { | ||||||
|  |         setErrorData(validationData); | ||||||
|  |         return; | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |  | ||||||
|     const newItem = formatInput |     const newItem = formatInput | ||||||
|       ? formatInput(inputValue) |       ? formatInput(inputValue) | ||||||
| @@ -160,6 +179,7 @@ export const MultiItemFieldInput = <T,>({ | |||||||
|           placeholder={placeholder} |           placeholder={placeholder} | ||||||
|           value={inputValue} |           value={inputValue} | ||||||
|           hotkeyScope={hotkeyScope} |           hotkeyScope={hotkeyScope} | ||||||
|  |           hasError={!errorData.isValid} | ||||||
|           renderInput={ |           renderInput={ | ||||||
|             renderInput |             renderInput | ||||||
|               ? (props) => |               ? (props) => | ||||||
| @@ -170,7 +190,7 @@ export const MultiItemFieldInput = <T,>({ | |||||||
|                   }) |                   }) | ||||||
|               : undefined |               : undefined | ||||||
|           } |           } | ||||||
|           onChange={(event) => setInputValue(event.target.value)} |           onChange={(event) => handleOnChange(event.target.value)} | ||||||
|           onEnter={handleSubmitInput} |           onEnter={handleSubmitInput} | ||||||
|           rightComponent={ |           rightComponent={ | ||||||
|             <LightIconButton |             <LightIconButton | ||||||
|   | |||||||
| @@ -0,0 +1,3 @@ | |||||||
|  | import { z } from 'zod'; | ||||||
|  |  | ||||||
|  | export const emailSchema = z.string().email(); | ||||||
| @@ -7,10 +7,14 @@ import { RGBA, TEXT_INPUT_STYLE } from 'twenty-ui'; | |||||||
| import { useRegisterInputEvents } from '@/object-record/record-field/meta-types/input/hooks/useRegisterInputEvents'; | import { useRegisterInputEvents } from '@/object-record/record-field/meta-types/input/hooks/useRegisterInputEvents'; | ||||||
| import { useCombinedRefs } from '~/hooks/useCombinedRefs'; | import { useCombinedRefs } from '~/hooks/useCombinedRefs'; | ||||||
|  |  | ||||||
| const StyledInput = styled.input<{ withRightComponent?: boolean }>` | const StyledInput = styled.input<{ | ||||||
|  |   withRightComponent?: boolean; | ||||||
|  |   hasError?: boolean; | ||||||
|  | }>` | ||||||
|   ${TEXT_INPUT_STYLE} |   ${TEXT_INPUT_STYLE} | ||||||
|  |  | ||||||
|   border: 1px solid ${({ theme }) => theme.border.color.medium}; |   border: 1px solid ${({ theme, hasError }) => | ||||||
|  |     hasError ? theme.border.color.danger : theme.border.color.medium}; | ||||||
|   border-radius: ${({ theme }) => theme.border.radius.sm}; |   border-radius: ${({ theme }) => theme.border.radius.sm}; | ||||||
|   box-sizing: border-box; |   box-sizing: border-box; | ||||||
|   font-weight: ${({ theme }) => theme.font.weight.medium}; |   font-weight: ${({ theme }) => theme.font.weight.medium}; | ||||||
| @@ -19,8 +23,10 @@ const StyledInput = styled.input<{ withRightComponent?: boolean }>` | |||||||
|   width: 100%; |   width: 100%; | ||||||
|  |  | ||||||
|   &:focus { |   &:focus { | ||||||
|     border-color: ${({ theme }) => theme.color.blue}; |     ${({ theme, hasError = false }) => { | ||||||
|     box-shadow: 0px 0px 0px 3px ${({ theme }) => RGBA(theme.color.blue, 0.1)}; |       if (hasError) return ''; | ||||||
|  |       return `box-shadow: 0px 0px 0px 3px ${RGBA(theme.color.blue, 0.1)}`; | ||||||
|  |     }}; | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   ${({ withRightComponent }) => |   ${({ withRightComponent }) => | ||||||
| @@ -44,6 +50,12 @@ const StyledRightContainer = styled.div` | |||||||
|   transform: translateY(-50%); |   transform: translateY(-50%); | ||||||
| `; | `; | ||||||
|  |  | ||||||
|  | const StyledErrorDiv = styled.div` | ||||||
|  |   color: ${({ theme }) => theme.color.red}; | ||||||
|  |   padding: 0 ${({ theme }) => theme.spacing(2)} | ||||||
|  |     ${({ theme }) => theme.spacing(1)}; | ||||||
|  | `; | ||||||
|  |  | ||||||
| type HTMLInputProps = InputHTMLAttributes<HTMLInputElement>; | type HTMLInputProps = InputHTMLAttributes<HTMLInputElement>; | ||||||
|  |  | ||||||
| export type DropdownMenuInputProps = HTMLInputProps & { | export type DropdownMenuInputProps = HTMLInputProps & { | ||||||
| @@ -60,6 +72,8 @@ export type DropdownMenuInputProps = HTMLInputProps & { | |||||||
|     autoFocus: HTMLInputProps['autoFocus']; |     autoFocus: HTMLInputProps['autoFocus']; | ||||||
|     placeholder: HTMLInputProps['placeholder']; |     placeholder: HTMLInputProps['placeholder']; | ||||||
|   }) => React.ReactNode; |   }) => React.ReactNode; | ||||||
|  |   error?: string | null; | ||||||
|  |   hasError?: boolean; | ||||||
| }; | }; | ||||||
|  |  | ||||||
| export const DropdownMenuInput = forwardRef< | export const DropdownMenuInput = forwardRef< | ||||||
| @@ -81,6 +95,8 @@ export const DropdownMenuInput = forwardRef< | |||||||
|       onTab, |       onTab, | ||||||
|       rightComponent, |       rightComponent, | ||||||
|       renderInput, |       renderInput, | ||||||
|  |       error = '', | ||||||
|  |       hasError = false, | ||||||
|     }, |     }, | ||||||
|     ref, |     ref, | ||||||
|   ) => { |   ) => { | ||||||
| @@ -99,6 +115,7 @@ export const DropdownMenuInput = forwardRef< | |||||||
|     }); |     }); | ||||||
|  |  | ||||||
|     return ( |     return ( | ||||||
|  |       <> | ||||||
|         <StyledInputContainer className={className}> |         <StyledInputContainer className={className}> | ||||||
|           {renderInput ? ( |           {renderInput ? ( | ||||||
|             renderInput({ |             renderInput({ | ||||||
| @@ -109,6 +126,7 @@ export const DropdownMenuInput = forwardRef< | |||||||
|             }) |             }) | ||||||
|           ) : ( |           ) : ( | ||||||
|             <StyledInput |             <StyledInput | ||||||
|  |               hasError={hasError} | ||||||
|               autoFocus={autoFocus} |               autoFocus={autoFocus} | ||||||
|               value={value} |               value={value} | ||||||
|               placeholder={placeholder} |               placeholder={placeholder} | ||||||
| @@ -121,6 +139,8 @@ export const DropdownMenuInput = forwardRef< | |||||||
|             <StyledRightContainer>{rightComponent}</StyledRightContainer> |             <StyledRightContainer>{rightComponent}</StyledRightContainer> | ||||||
|           )} |           )} | ||||||
|         </StyledInputContainer> |         </StyledInputContainer> | ||||||
|  |         {error && <StyledErrorDiv>{error}</StyledErrorDiv>} | ||||||
|  |       </> | ||||||
|     ); |     ); | ||||||
|   }, |   }, | ||||||
| ); | ); | ||||||
|   | |||||||
		Reference in New Issue
	
	Block a user
	 sid0-0
					sid0-0