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:
sid0-0 2024-10-03 22:25:29 +05:30 committed by GitHub
parent 04579144ca
commit a946c6a33d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 87 additions and 31 deletions

View File

@ -1,6 +1,7 @@
import { useEmailsField } from '@/object-record/record-field/meta-types/hooks/useEmailsField';
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 { FieldMetadataType } from '~/generated-metadata/graphql';
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;
return (
@ -38,6 +47,7 @@ export const EmailsFieldInput = ({ onCancel }: EmailsFieldInputProps) => {
onCancel={onCancel}
placeholder="Email"
fieldMetadataType={FieldMetadataType.Emails}
validateInput={validateInput}
renderItem={({
value: email,
index,

View File

@ -51,7 +51,10 @@ export const LinksFieldInput = ({ onCancel }: LinksFieldInputProps) => {
onCancel={onCancel}
placeholder="URL"
fieldMetadataType={FieldMetadataType.Links}
validateInput={(input) => absoluteUrlSchema.safeParse(input).success}
validateInput={(input) => ({
isValid: absoluteUrlSchema.safeParse(input).success,
errorMessage: '',
})}
formatInput={(input) => ({ url: input, label: '' })}
renderItem={({
value: link,

View File

@ -30,7 +30,7 @@ type MultiItemFieldInputProps<T> = {
onPersist: (updatedItems: T[]) => void;
onCancel?: () => void;
placeholder: string;
validateInput?: (input: string) => boolean;
validateInput?: (input: string) => { isValid: boolean; errorMessage: string };
formatInput?: (input: string) => T;
renderItem: (props: {
value: T;
@ -74,8 +74,21 @@ export const MultiItemFieldInput = <T,>({
const [isInputDisplayed, setIsInputDisplayed] = useState(false);
const [inputValue, setInputValue] = useState('');
const [itemToEditIndex, setItemToEditIndex] = useState(-1);
const [errorData, setErrorData] = useState({
isValid: true,
errorMessage: '',
});
const isAddingNewItem = itemToEditIndex === -1;
const handleOnChange = (value: string) => {
setInputValue(value);
if (!validateInput) return;
if (errorData.isValid) {
setErrorData(errorData);
}
};
const handleAddButtonClick = () => {
setItemToEditIndex(-1);
setIsInputDisplayed(true);
@ -105,7 +118,13 @@ export const MultiItemFieldInput = <T,>({
};
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
? formatInput(inputValue)
@ -160,6 +179,7 @@ export const MultiItemFieldInput = <T,>({
placeholder={placeholder}
value={inputValue}
hotkeyScope={hotkeyScope}
hasError={!errorData.isValid}
renderInput={
renderInput
? (props) =>
@ -170,7 +190,7 @@ export const MultiItemFieldInput = <T,>({
})
: undefined
}
onChange={(event) => setInputValue(event.target.value)}
onChange={(event) => handleOnChange(event.target.value)}
onEnter={handleSubmitInput}
rightComponent={
<LightIconButton

View File

@ -0,0 +1,3 @@
import { z } from 'zod';
export const emailSchema = z.string().email();

View File

@ -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 { useCombinedRefs } from '~/hooks/useCombinedRefs';
const StyledInput = styled.input<{ withRightComponent?: boolean }>`
const StyledInput = styled.input<{
withRightComponent?: boolean;
hasError?: boolean;
}>`
${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};
box-sizing: border-box;
font-weight: ${({ theme }) => theme.font.weight.medium};
@ -19,8 +23,10 @@ const StyledInput = styled.input<{ withRightComponent?: boolean }>`
width: 100%;
&:focus {
border-color: ${({ theme }) => theme.color.blue};
box-shadow: 0px 0px 0px 3px ${({ theme }) => RGBA(theme.color.blue, 0.1)};
${({ theme, hasError = false }) => {
if (hasError) return '';
return `box-shadow: 0px 0px 0px 3px ${RGBA(theme.color.blue, 0.1)}`;
}};
}
${({ withRightComponent }) =>
@ -44,6 +50,12 @@ const StyledRightContainer = styled.div`
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>;
export type DropdownMenuInputProps = HTMLInputProps & {
@ -60,6 +72,8 @@ export type DropdownMenuInputProps = HTMLInputProps & {
autoFocus: HTMLInputProps['autoFocus'];
placeholder: HTMLInputProps['placeholder'];
}) => React.ReactNode;
error?: string | null;
hasError?: boolean;
};
export const DropdownMenuInput = forwardRef<
@ -81,6 +95,8 @@ export const DropdownMenuInput = forwardRef<
onTab,
rightComponent,
renderInput,
error = '',
hasError = false,
},
ref,
) => {
@ -99,28 +115,32 @@ export const DropdownMenuInput = forwardRef<
});
return (
<StyledInputContainer className={className}>
{renderInput ? (
renderInput({
value,
onChange,
autoFocus,
placeholder,
})
) : (
<StyledInput
autoFocus={autoFocus}
value={value}
placeholder={placeholder}
onChange={onChange}
ref={combinedRef}
withRightComponent={!!rightComponent}
/>
)}
{!!rightComponent && (
<StyledRightContainer>{rightComponent}</StyledRightContainer>
)}
</StyledInputContainer>
<>
<StyledInputContainer className={className}>
{renderInput ? (
renderInput({
value,
onChange,
autoFocus,
placeholder,
})
) : (
<StyledInput
hasError={hasError}
autoFocus={autoFocus}
value={value}
placeholder={placeholder}
onChange={onChange}
ref={combinedRef}
withRightComponent={!!rightComponent}
/>
)}
{!!rightComponent && (
<StyledRightContainer>{rightComponent}</StyledRightContainer>
)}
</StyledInputContainer>
{error && <StyledErrorDiv>{error}</StyledErrorDiv>}
</>
);
},
);