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
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 { 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,

View File

@@ -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,

View File

@@ -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

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 { 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,28 +115,32 @@ export const DropdownMenuInput = forwardRef<
}); });
return ( return (
<StyledInputContainer className={className}> <>
{renderInput ? ( <StyledInputContainer className={className}>
renderInput({ {renderInput ? (
value, renderInput({
onChange, value,
autoFocus, onChange,
placeholder, autoFocus,
}) placeholder,
) : ( })
<StyledInput ) : (
autoFocus={autoFocus} <StyledInput
value={value} hasError={hasError}
placeholder={placeholder} autoFocus={autoFocus}
onChange={onChange} value={value}
ref={combinedRef} placeholder={placeholder}
withRightComponent={!!rightComponent} onChange={onChange}
/> ref={combinedRef}
)} withRightComponent={!!rightComponent}
{!!rightComponent && ( />
<StyledRightContainer>{rightComponent}</StyledRightContainer> )}
)} {!!rightComponent && (
</StyledInputContainer> <StyledRightContainer>{rightComponent}</StyledRightContainer>
)}
</StyledInputContainer>
{error && <StyledErrorDiv>{error}</StyledErrorDiv>}
</>
); );
}, },
); );