mirror of
https://github.com/lingble/twenty.git
synced 2025-10-30 12:22:29 +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,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>}
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user