Skip to content

Commit

Permalink
fix: validate emails in record-fields (twentyhq#7245)
Browse files Browse the repository at this point in the history
fix: twentyhq#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 <[email protected]>
Co-authored-by: bosiraphael <[email protected]>
Co-authored-by: Félix Malfait <[email protected]>
  • Loading branch information
4 people authored and harshit078 committed Oct 14, 2024
1 parent f7c3646 commit 62a14df
Show file tree
Hide file tree
Showing 5 changed files with 87 additions and 31 deletions.
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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 (
Expand All @@ -38,6 +47,7 @@ export const EmailsFieldInput = ({ onCancel }: EmailsFieldInputProps) => {
onCancel={onCancel}
placeholder="Email"
fieldMetadataType={FieldMetadataType.Emails}
validateInput={validateInput}
renderItem={({
value: email,
index,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -160,6 +179,7 @@ export const MultiItemFieldInput = <T,>({
placeholder={placeholder}
value={inputValue}
hotkeyScope={hotkeyScope}
hasError={!errorData.isValid}
renderInput={
renderInput
? (props) =>
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { z } from 'zod';

export const emailSchema = z.string().email();
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand All @@ -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 }) =>
Expand All @@ -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 & {
Expand All @@ -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<
Expand All @@ -81,6 +95,8 @@ export const DropdownMenuInput = forwardRef<
onTab,
rightComponent,
renderInput,
error = '',
hasError = false,
},
ref,
) => {
Expand All @@ -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>}
</>
);
},
);

0 comments on commit 62a14df

Please sign in to comment.