diff --git a/packages/twenty-front/src/modules/object-record/hooks/useUpdateOneRecord.ts b/packages/twenty-front/src/modules/object-record/hooks/useUpdateOneRecord.ts index a362a324dd..d51161409a 100644 --- a/packages/twenty-front/src/modules/object-record/hooks/useUpdateOneRecord.ts +++ b/packages/twenty-front/src/modules/object-record/hooks/useUpdateOneRecord.ts @@ -20,8 +20,8 @@ import { computeOptimisticRecordFromInput } from '@/object-record/utils/computeO import { getUpdatedFieldsFromRecordInput } from '@/object-record/utils/getUpdatedFieldsFromRecordInput'; import { getUpdateOneRecordMutationResponseField } from '@/object-record/utils/getUpdateOneRecordMutationResponseField'; import { sanitizeRecordInput } from '@/object-record/utils/sanitizeRecordInput'; -import { isNull } from '@sniptt/guards'; import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue'; +import { isNull } from '@sniptt/guards'; import { isDefined } from 'twenty-shared/utils'; import { buildRecordFromKeysWithSameValue } from '~/utils/array/buildRecordFromKeysWithSameValue'; @@ -239,6 +239,28 @@ export const useUpdateOneRecord = () => { upsertRecordsInStore, }); + const optimisticallyUpdatedFieldsToRestore = Object.keys( + optimisticRecordInput, + ).reduce>( + (restoredFields, fieldName) => ({ + ...restoredFields, + [fieldName]: cachedRecordKeys.has(fieldName) + ? cachedRecord?.[fieldName] + : null, + }), + {}, + ); + + upsertRecordsInStore({ + partialRecords: [ + { + id: idToUpdate, + __typename: getObjectTypename(objectMetadataItem.nameSingular), + ...optimisticallyUpdatedFieldsToRestore, + }, + ], + }); + throw error; }); diff --git a/packages/twenty-front/src/modules/object-record/record-field/ui/meta-types/input/components/EmailsFieldInput.tsx b/packages/twenty-front/src/modules/object-record/record-field/ui/meta-types/input/components/EmailsFieldInput.tsx index 466a21bbe4..1c9222e91c 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/ui/meta-types/input/components/EmailsFieldInput.tsx +++ b/packages/twenty-front/src/modules/object-record/record-field/ui/meta-types/input/components/EmailsFieldInput.tsx @@ -5,12 +5,11 @@ import { MULTI_ITEM_FIELD_INPUT_DROPDOWN_ID_PREFIX } from '@/object-record/recor import { recordFieldInputIsFieldInErrorComponentState } from '@/object-record/record-field/ui/states/recordFieldInputIsFieldInErrorComponentState'; import { type FieldEmailsValue } from '@/object-record/record-field/ui/types/FieldMetadata'; import { emailsFieldValueSchema } from '@/object-record/record-field/ui/validation-schemas/emailsFieldValueSchema'; -import { emailSchema } from '@/object-record/record-field/ui/validation-schemas/emailSchema'; import { useSetAtomComponentState } from '@/ui/utilities/state/jotai/hooks/useSetAtomComponentState'; import { useLingui } from '@lingui/react/macro'; import { useCallback, useContext, useMemo } from 'react'; import { MULTI_ITEM_FIELD_DEFAULT_MAX_VALUES } from 'twenty-shared/constants'; -import { isDefined } from 'twenty-shared/utils'; +import { emailSchema, isDefined } from 'twenty-shared/utils'; import { FieldMetadataType } from '~/generated-metadata/graphql'; import { useCopyToClipboard } from '~/hooks/useCopyToClipboard'; import { MultiItemFieldInput } from './MultiItemFieldInput'; diff --git a/packages/twenty-front/src/modules/object-record/record-field/ui/validation-schemas/emailSchema.ts b/packages/twenty-front/src/modules/object-record/record-field/ui/validation-schemas/emailSchema.ts deleted file mode 100644 index adcbf8eaf0..0000000000 --- a/packages/twenty-front/src/modules/object-record/record-field/ui/validation-schemas/emailSchema.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { z } from 'zod'; - -export const emailSchema = z.email(); diff --git a/packages/twenty-front/src/modules/object-record/spreadsheet-import/utils/getSpreadSheetFieldValidationDefinitions.ts b/packages/twenty-front/src/modules/object-record/spreadsheet-import/utils/getSpreadSheetFieldValidationDefinitions.ts index e36d71cb6e..48d3304a16 100644 --- a/packages/twenty-front/src/modules/object-record/spreadsheet-import/utils/getSpreadSheetFieldValidationDefinitions.ts +++ b/packages/twenty-front/src/modules/object-record/spreadsheet-import/utils/getSpreadSheetFieldValidationDefinitions.ts @@ -1,5 +1,4 @@ import { isFieldRatingValue } from '@/object-record/record-field/ui/types/guards/isFieldRatingValue'; -import { emailSchema } from '@/object-record/record-field/ui/validation-schemas/emailSchema'; import { type SpreadsheetImportFieldValidationDefinition } from '@/spreadsheet-import/types'; import { t } from '@lingui/core/macro'; import { isDate, isString } from '@sniptt/guards'; @@ -7,6 +6,7 @@ import { parsePhoneNumberWithError } from 'libphonenumber-js'; import { RATING_VALUES } from 'twenty-shared/constants'; import { absoluteUrlSchema, + emailSchema, getCountryCodesForCallingCode, isDefined, isValidCountryCode, diff --git a/packages/twenty-server/src/engine/api/common/common-args-processors/data-arg-processor/validator-utils/validate-emails-primary-email-subfield-or-throw.util.ts b/packages/twenty-server/src/engine/api/common/common-args-processors/data-arg-processor/validator-utils/validate-emails-primary-email-subfield-or-throw.util.ts index 6fa6495747..92b35cd97a 100644 --- a/packages/twenty-server/src/engine/api/common/common-args-processors/data-arg-processor/validator-utils/validate-emails-primary-email-subfield-or-throw.util.ts +++ b/packages/twenty-server/src/engine/api/common/common-args-processors/data-arg-processor/validator-utils/validate-emails-primary-email-subfield-or-throw.util.ts @@ -2,7 +2,7 @@ import { inspect } from 'util'; import { msg } from '@lingui/core/macro'; import { isNonEmptyString, isNull } from '@sniptt/guards'; -import { z } from 'zod'; +import { emailSchema } from 'twenty-shared/utils'; import { CommonQueryRunnerException, @@ -25,10 +25,7 @@ export const validateEmailsPrimaryEmailSubfieldOrThrow = ( ); } - if ( - !z.email({ pattern: z.regexes.unicodeEmail }).safeParse(value).success && - isNonEmptyString(value) - ) { + if (!emailSchema.safeParse(value).success && isNonEmptyString(value)) { const inspectedValue = inspect(value); throw new CommonQueryRunnerException( diff --git a/packages/twenty-shared/src/utils/index.ts b/packages/twenty-shared/src/utils/index.ts index 7bbd88ecc4..a06f426d46 100644 --- a/packages/twenty-shared/src/utils/index.ts +++ b/packages/twenty-shared/src/utils/index.ts @@ -226,6 +226,7 @@ export { normalizeUrlOrigin } from './url/normalizeUrlOrigin'; export { safeDecodeURIComponent } from './url/safeDecodeURIComponent'; export { uuidToBase36 } from './uuidToBase36'; export { assertIsDefinedOrThrow } from './validation/assertIsDefinedOrThrow'; +export { emailSchema } from './validation/emailSchema'; export { isDefined } from './validation/isDefined'; export { isEmptyObject } from './validation/isEmptyObject'; export { isLabelIdentifierFieldMetadataTypes } from './validation/isLabelIdentifierFieldMetadataTypes'; diff --git a/packages/twenty-shared/src/utils/validation/__tests__/emailSchema.test.ts b/packages/twenty-shared/src/utils/validation/__tests__/emailSchema.test.ts new file mode 100644 index 0000000000..12a30290c8 --- /dev/null +++ b/packages/twenty-shared/src/utils/validation/__tests__/emailSchema.test.ts @@ -0,0 +1,27 @@ +import { emailSchema } from '@/utils/validation/emailSchema'; + +describe('emailSchema', () => { + it('should accept valid emails', () => { + expect(emailSchema.safeParse('john.doe@example.com').success).toBe(true); + expect(emailSchema.safeParse('jöhn@example.com').success).toBe(true); + expect(emailSchema.safeParse('john+tag@sub.example.co').success).toBe(true); + }); + + it('should reject invalid emails', () => { + expect(emailSchema.safeParse('not-an-email').success).toBe(false); + expect(emailSchema.safeParse('john@').success).toBe(false); + expect(emailSchema.safeParse('@example.com').success).toBe(false); + expect(emailSchema.safeParse('john doe@example.com').success).toBe(false); + }); + + it('should reject emails with a local part longer than 64 characters', () => { + const longLocalPart = 'a'.repeat(65); + + expect(emailSchema.safeParse(`${longLocalPart}@example.com`).success).toBe( + false, + ); + expect(emailSchema.safeParse(`${'a'.repeat(64)}@example.com`).success).toBe( + true, + ); + }); +}); diff --git a/packages/twenty-shared/src/utils/validation/emailSchema.ts b/packages/twenty-shared/src/utils/validation/emailSchema.ts new file mode 100644 index 0000000000..316564bf85 --- /dev/null +++ b/packages/twenty-shared/src/utils/validation/emailSchema.ts @@ -0,0 +1,3 @@ +import { z } from 'zod'; + +export const emailSchema = z.email({ pattern: z.regexes.unicodeEmail });