From 909b8e5ca107d101d1604798216d58e436f2c1e6 Mon Sep 17 00:00:00 2001 From: drminh2807 Date: Sun, 6 Jul 2025 17:44:14 +0700 Subject: [PATCH 1/6] fix: Error message is not translated to Spanish after trying to move report to a deleted WS --- src/components/DotIndicatorMessage.tsx | 7 ++++--- src/components/ErrorMessageRow.tsx | 5 +++-- src/components/MessagesRow.tsx | 3 ++- src/components/OfflineWithFeedback.tsx | 3 ++- src/languages/types.ts | 7 ++++++- src/libs/ErrorUtils.ts | 21 ++++++++++++++++++++- src/libs/actions/Report.ts | 4 ++-- 7 files changed, 39 insertions(+), 11 deletions(-) diff --git a/src/components/DotIndicatorMessage.tsx b/src/components/DotIndicatorMessage.tsx index dd270f5e6ca6..0d13594993eb 100644 --- a/src/components/DotIndicatorMessage.tsx +++ b/src/components/DotIndicatorMessage.tsx @@ -7,9 +7,10 @@ import useLocalize from '@hooks/useLocalize'; import useStyleUtils from '@hooks/useStyleUtils'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; -import {isReceiptError} from '@libs/ErrorUtils'; +import {isReceiptError, isTranslationPathError} from '@libs/ErrorUtils'; import fileDownload from '@libs/fileDownload'; import handleRetryPress from '@libs/ReceiptUploadRetryHandler'; +import type {TranslationPathError} from '@src/languages/types'; import type {ReceiptError} from '@src/types/onyx/Transaction'; import ConfirmModal from './ConfirmModal'; import Icon from './Icon'; @@ -25,7 +26,7 @@ type DotIndicatorMessageProps = { * timestamp: 'message', * } */ - messages: Record; + messages: Record; /** The type of message, 'error' shows a red dot, 'success' shows a green dot */ type: 'error' | 'success'; @@ -109,7 +110,7 @@ function DotIndicatorMessage({messages = {}, style, type, textStyles, dismissErr key={index} style={[StyleUtils.getDotIndicatorTextStyles(isErrorMessage), textStyles]} > - {message} + {isTranslationPathError(message) ? translate(message.translationPath) : message} ); }; diff --git a/src/components/ErrorMessageRow.tsx b/src/components/ErrorMessageRow.tsx index 379e5f903313..03ee85017f40 100644 --- a/src/components/ErrorMessageRow.tsx +++ b/src/components/ErrorMessageRow.tsx @@ -1,6 +1,7 @@ import mapValues from 'lodash/mapValues'; import React from 'react'; import type {StyleProp, ViewStyle} from 'react-native'; +import type {TranslationPathError, TranslationPathErrors} from '@src/languages/types'; import type * as OnyxCommon from '@src/types/onyx/OnyxCommon'; import type {ReceiptError, ReceiptErrors} from '@src/types/onyx/Transaction'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; @@ -8,7 +9,7 @@ import MessagesRow from './MessagesRow'; type ErrorMessageRowProps = { /** The errors to display */ - errors?: OnyxCommon.Errors | ReceiptErrors | null; + errors?: OnyxCommon.Errors | ReceiptErrors | TranslationPathErrors | null; /** Additional style object for the error row */ errorRowStyles?: StyleProp; @@ -26,7 +27,7 @@ type ErrorMessageRowProps = { function ErrorMessageRow({errors, errorRowStyles, onClose, canDismissError = true, dismissError}: ErrorMessageRowProps) { // Some errors have a null message. This is used to apply opacity only and to avoid showing redundant messages. const errorEntries = Object.entries(errors ?? {}); - const filteredErrorEntries = errorEntries.filter((errorEntry): errorEntry is [string, string | ReceiptError] => errorEntry[1] !== null); + const filteredErrorEntries = errorEntries.filter((errorEntry): errorEntry is [string, string | ReceiptError | TranslationPathError] => errorEntry[1] !== null); const errorMessages = mapValues(Object.fromEntries(filteredErrorEntries), (error) => error); const hasErrorMessages = !isEmptyObject(errorMessages); diff --git a/src/components/MessagesRow.tsx b/src/components/MessagesRow.tsx index f61dbc39adaa..c691d58d4a06 100644 --- a/src/components/MessagesRow.tsx +++ b/src/components/MessagesRow.tsx @@ -7,6 +7,7 @@ import useThemeStyles from '@hooks/useThemeStyles'; import CONST from '@src/CONST'; import type {ReceiptError} from '@src/types/onyx/Transaction'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; +import type { TranslationPathError } from '@src/languages/types'; import DotIndicatorMessage from './DotIndicatorMessage'; import Icon from './Icon'; import * as Expensicons from './Icon/Expensicons'; @@ -15,7 +16,7 @@ import Tooltip from './Tooltip'; type MessagesRowProps = { /** The messages to display */ - messages: Record; + messages: Record; /** The type of message, 'error' shows a red dot, 'success' shows a green dot */ type: 'error' | 'success'; diff --git a/src/components/OfflineWithFeedback.tsx b/src/components/OfflineWithFeedback.tsx index 04a0c12cd9c1..e4d30bd9c178 100644 --- a/src/components/OfflineWithFeedback.tsx +++ b/src/components/OfflineWithFeedback.tsx @@ -12,6 +12,7 @@ import type * as OnyxCommon from '@src/types/onyx/OnyxCommon'; import type {ReceiptErrors} from '@src/types/onyx/Transaction'; import type ChildrenProps from '@src/types/utils/ChildrenProps'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; +import type { TranslationPathErrors } from '@src/languages/types'; import CustomStylesForChildrenProvider from './CustomStylesForChildrenProvider'; import ErrorMessageRow from './ErrorMessageRow'; @@ -29,7 +30,7 @@ type OfflineWithFeedbackProps = ChildrenProps & { shouldHideOnDelete?: boolean; /** The errors to display */ - errors?: OnyxCommon.Errors | ReceiptErrors | null; + errors?: OnyxCommon.Errors | ReceiptErrors | TranslationPathErrors | null; /** Whether we should show the error messages */ shouldShowErrorMessages?: boolean; diff --git a/src/languages/types.ts b/src/languages/types.ts index abcf0086e4ee..6e2216de38e8 100644 --- a/src/languages/types.ts +++ b/src/languages/types.ts @@ -76,6 +76,11 @@ type DefaultTranslation = typeof en; */ type TranslationPaths = FlattenObject; +type TranslationPathError = { + translationPath: TranslationPaths +} + +type TranslationPathErrors = Record; /** * Flattened default translation object with its values */ @@ -94,4 +99,4 @@ type TranslationParameters = FlatTranslationsObje : Args : never[]; -export type {TranslationDeepObject, TranslationPaths, PluralForm, FlatTranslationsObject, TranslationParameters}; +export type {TranslationDeepObject, TranslationPaths, PluralForm, FlatTranslationsObject, TranslationParameters, TranslationPathError, TranslationPathErrors}; diff --git a/src/libs/ErrorUtils.ts b/src/libs/ErrorUtils.ts index b880239b8abf..6fe5758f18c5 100644 --- a/src/libs/ErrorUtils.ts +++ b/src/libs/ErrorUtils.ts @@ -1,7 +1,7 @@ import mapValues from 'lodash/mapValues'; import type {OnyxEntry} from 'react-native-onyx'; import CONST from '@src/CONST'; -import type {TranslationPaths} from '@src/languages/types'; +import type {TranslationPathError, TranslationPathErrors, TranslationPaths} from '@src/languages/types'; import type {ErrorFields, Errors} from '@src/types/onyx/OnyxCommon'; import type Response from '@src/types/onyx/Response'; import type {ReceiptError} from '@src/types/onyx/Transaction'; @@ -45,6 +45,10 @@ function getMicroSecondOnyxErrorWithTranslationKey(error: TranslationPaths, erro return {[errorKey ?? DateUtils.getMicroseconds()]: Localize.translateLocal(error)}; } +function getMicroSecondTranslationErrorWithTranslationKey(error: TranslationPaths, errorKey?: number): TranslationPathErrors { + return {[errorKey ?? DateUtils.getMicroseconds()]: {translationPath: error}}; +} + /** * Creates an error object with a timestamp (in microseconds) as the key and the error message as the value. * @param error - The error message. @@ -197,6 +201,19 @@ function isReceiptError(message: unknown): message is ReceiptError { return ((message as Record)?.error ?? '') === CONST.IOU.RECEIPT_ERROR; } +function isTranslationPathError(message: unknown): message is TranslationPathError { + if (typeof message === 'string') { + return false; + } + if (Array.isArray(message)) { + return false; + } + if (Object.keys(message as Record).length === 0) { + return false; + } + return (message as Record)?.translationPath !== undefined; +} + export { addErrorMessage, getAuthenticateErrorMessage, @@ -212,6 +229,8 @@ export { getMicroSecondOnyxErrorWithMessage, getMicroSecondOnyxErrorObject, isReceiptError, + isTranslationPathError, + getMicroSecondTranslationErrorWithTranslationKey, }; export type {OnyxDataWithErrors}; diff --git a/src/libs/actions/Report.ts b/src/libs/actions/Report.ts index 12e8dd2fb7d3..3a1e0a9310e3 100644 --- a/src/libs/actions/Report.ts +++ b/src/libs/actions/Report.ts @@ -67,7 +67,7 @@ import * as Environment from '@libs/Environment/Environment'; import {getOldDotURLFromEnvironment} from '@libs/Environment/Environment'; import getEnvironment from '@libs/Environment/getEnvironment'; import type EnvironmentType from '@libs/Environment/getEnvironment/types'; -import {getMicroSecondOnyxErrorWithTranslationKey} from '@libs/ErrorUtils'; +import {getMicroSecondOnyxErrorWithTranslationKey, getMicroSecondTranslationErrorWithTranslationKey} from '@libs/ErrorUtils'; import fileDownload from '@libs/fileDownload'; import getIsNarrowLayout from '@libs/getIsNarrowLayout'; import HttpUtils from '@libs/HttpUtils'; @@ -5642,7 +5642,7 @@ function buildOptimisticChangePolicyData(report: Report, policyID: string, repor key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`, value: { [optimisticMovedReportAction.reportActionID]: { - errors: getMicroSecondOnyxErrorWithTranslationKey('common.genericErrorMessage'), + errors: getMicroSecondTranslationErrorWithTranslationKey('common.genericErrorMessage'), }, }, }); From 8f595d7c963c7ac45a9df74a4f08920c412e52c9 Mon Sep 17 00:00:00 2001 From: drminh2807 Date: Thu, 17 Jul 2025 10:55:05 +0700 Subject: [PATCH 2/6] Refactor error handling to use TranslationKeyError instead of TranslationPathError across multiple components and utilities. Update related types and functions for consistency. --- src/components/DotIndicatorMessage.tsx | 8 +-- src/components/ErrorMessageRow.tsx | 6 +- src/components/MessagesRow.tsx | 4 +- src/components/OfflineWithFeedback.tsx | 4 +- src/languages/types.ts | 8 +-- src/libs/ErrorUtils.ts | 25 +++++--- tests/unit/ErrorUtilsTest.ts | 88 ++++++++++++++++++++++++++ 7 files changed, 119 insertions(+), 24 deletions(-) diff --git a/src/components/DotIndicatorMessage.tsx b/src/components/DotIndicatorMessage.tsx index 0d13594993eb..6b938b115d79 100644 --- a/src/components/DotIndicatorMessage.tsx +++ b/src/components/DotIndicatorMessage.tsx @@ -7,10 +7,10 @@ import useLocalize from '@hooks/useLocalize'; import useStyleUtils from '@hooks/useStyleUtils'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; -import {isReceiptError, isTranslationPathError} from '@libs/ErrorUtils'; +import {isReceiptError, isTranslationKeyError} from '@libs/ErrorUtils'; import fileDownload from '@libs/fileDownload'; import handleRetryPress from '@libs/ReceiptUploadRetryHandler'; -import type {TranslationPathError} from '@src/languages/types'; +import type {TranslationKeyError} from '@src/languages/types'; import type {ReceiptError} from '@src/types/onyx/Transaction'; import ConfirmModal from './ConfirmModal'; import Icon from './Icon'; @@ -26,7 +26,7 @@ type DotIndicatorMessageProps = { * timestamp: 'message', * } */ - messages: Record; + messages: Record; /** The type of message, 'error' shows a red dot, 'success' shows a green dot */ type: 'error' | 'success'; @@ -110,7 +110,7 @@ function DotIndicatorMessage({messages = {}, style, type, textStyles, dismissErr key={index} style={[StyleUtils.getDotIndicatorTextStyles(isErrorMessage), textStyles]} > - {isTranslationPathError(message) ? translate(message.translationPath) : message} + {isTranslationKeyError(message) ? translate(message.translationKey) : message} ); }; diff --git a/src/components/ErrorMessageRow.tsx b/src/components/ErrorMessageRow.tsx index 03ee85017f40..930cd33c1643 100644 --- a/src/components/ErrorMessageRow.tsx +++ b/src/components/ErrorMessageRow.tsx @@ -1,7 +1,7 @@ import mapValues from 'lodash/mapValues'; import React from 'react'; import type {StyleProp, ViewStyle} from 'react-native'; -import type {TranslationPathError, TranslationPathErrors} from '@src/languages/types'; +import type {TranslationKeyError, TranslationKeyErrors} from '@src/languages/types'; import type * as OnyxCommon from '@src/types/onyx/OnyxCommon'; import type {ReceiptError, ReceiptErrors} from '@src/types/onyx/Transaction'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; @@ -9,7 +9,7 @@ import MessagesRow from './MessagesRow'; type ErrorMessageRowProps = { /** The errors to display */ - errors?: OnyxCommon.Errors | ReceiptErrors | TranslationPathErrors | null; + errors?: OnyxCommon.Errors | ReceiptErrors | TranslationKeyErrors | null; /** Additional style object for the error row */ errorRowStyles?: StyleProp; @@ -27,7 +27,7 @@ type ErrorMessageRowProps = { function ErrorMessageRow({errors, errorRowStyles, onClose, canDismissError = true, dismissError}: ErrorMessageRowProps) { // Some errors have a null message. This is used to apply opacity only and to avoid showing redundant messages. const errorEntries = Object.entries(errors ?? {}); - const filteredErrorEntries = errorEntries.filter((errorEntry): errorEntry is [string, string | ReceiptError | TranslationPathError] => errorEntry[1] !== null); + const filteredErrorEntries = errorEntries.filter((errorEntry): errorEntry is [string, string | ReceiptError | TranslationKeyError] => errorEntry[1] !== null); const errorMessages = mapValues(Object.fromEntries(filteredErrorEntries), (error) => error); const hasErrorMessages = !isEmptyObject(errorMessages); diff --git a/src/components/MessagesRow.tsx b/src/components/MessagesRow.tsx index c691d58d4a06..7831304fccd5 100644 --- a/src/components/MessagesRow.tsx +++ b/src/components/MessagesRow.tsx @@ -7,7 +7,7 @@ import useThemeStyles from '@hooks/useThemeStyles'; import CONST from '@src/CONST'; import type {ReceiptError} from '@src/types/onyx/Transaction'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; -import type { TranslationPathError } from '@src/languages/types'; +import type { TranslationKeyError } from '@src/languages/types'; import DotIndicatorMessage from './DotIndicatorMessage'; import Icon from './Icon'; import * as Expensicons from './Icon/Expensicons'; @@ -16,7 +16,7 @@ import Tooltip from './Tooltip'; type MessagesRowProps = { /** The messages to display */ - messages: Record; + messages: Record; /** The type of message, 'error' shows a red dot, 'success' shows a green dot */ type: 'error' | 'success'; diff --git a/src/components/OfflineWithFeedback.tsx b/src/components/OfflineWithFeedback.tsx index d7bf6887799c..7a352aac64d8 100644 --- a/src/components/OfflineWithFeedback.tsx +++ b/src/components/OfflineWithFeedback.tsx @@ -12,7 +12,7 @@ import type * as OnyxCommon from '@src/types/onyx/OnyxCommon'; import type {ReceiptErrors} from '@src/types/onyx/Transaction'; import type ChildrenProps from '@src/types/utils/ChildrenProps'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; -import type { TranslationPathErrors } from '@src/languages/types'; +import type { TranslationKeyErrors } from '@src/languages/types'; import CustomStylesForChildrenProvider from './CustomStylesForChildrenProvider'; import ErrorMessageRow from './ErrorMessageRow'; import ImageSVG from './ImageSVG'; @@ -31,7 +31,7 @@ type OfflineWithFeedbackProps = ChildrenProps & { shouldHideOnDelete?: boolean; /** The errors to display */ - errors?: OnyxCommon.Errors | ReceiptErrors | TranslationPathErrors | null; + errors?: OnyxCommon.Errors | ReceiptErrors | TranslationKeyErrors | null; /** Whether we should show the error messages */ shouldShowErrorMessages?: boolean; diff --git a/src/languages/types.ts b/src/languages/types.ts index 6e2216de38e8..d219fc40ce29 100644 --- a/src/languages/types.ts +++ b/src/languages/types.ts @@ -76,11 +76,11 @@ type DefaultTranslation = typeof en; */ type TranslationPaths = FlattenObject; -type TranslationPathError = { - translationPath: TranslationPaths +type TranslationKeyError = { + translationKey: TranslationPaths } -type TranslationPathErrors = Record; +type TranslationKeyErrors = Record; /** * Flattened default translation object with its values */ @@ -99,4 +99,4 @@ type TranslationParameters = FlatTranslationsObje : Args : never[]; -export type {TranslationDeepObject, TranslationPaths, PluralForm, FlatTranslationsObject, TranslationParameters, TranslationPathError, TranslationPathErrors}; +export type {TranslationDeepObject, TranslationPaths, PluralForm, FlatTranslationsObject, TranslationParameters, TranslationKeyError, TranslationKeyErrors}; diff --git a/src/libs/ErrorUtils.ts b/src/libs/ErrorUtils.ts index 6fe5758f18c5..27f38defeb8a 100644 --- a/src/libs/ErrorUtils.ts +++ b/src/libs/ErrorUtils.ts @@ -1,12 +1,12 @@ import mapValues from 'lodash/mapValues'; import type {OnyxEntry} from 'react-native-onyx'; import CONST from '@src/CONST'; -import type {TranslationPathError, TranslationPathErrors, TranslationPaths} from '@src/languages/types'; +import type {TranslationKeyError, TranslationKeyErrors, TranslationPaths} from '@src/languages/types'; import type {ErrorFields, Errors} from '@src/types/onyx/OnyxCommon'; import type Response from '@src/types/onyx/Response'; import type {ReceiptError} from '@src/types/onyx/Transaction'; import DateUtils from './DateUtils'; -import * as Localize from './Localize'; +import {translateLocal} from './Localize'; function getAuthenticateErrorMessage(response: Response): TranslationPaths { switch (response.jsonCode) { @@ -42,11 +42,15 @@ function getAuthenticateErrorMessage(response: Response): TranslationPaths { * @param error - The translation key for the error message. */ function getMicroSecondOnyxErrorWithTranslationKey(error: TranslationPaths, errorKey?: number): Errors { - return {[errorKey ?? DateUtils.getMicroseconds()]: Localize.translateLocal(error)}; + return {[errorKey ?? DateUtils.getMicroseconds()]: translateLocal(error)}; } -function getMicroSecondTranslationErrorWithTranslationKey(error: TranslationPaths, errorKey?: number): TranslationPathErrors { - return {[errorKey ?? DateUtils.getMicroseconds()]: {translationPath: error}}; +/** + * Creates an error object with a timestamp (in microseconds) as the key and the translation key as the value. + * @param translationKey - The translation key for the error message. + */ +function getMicroSecondTranslationErrorWithTranslationKey(translationKey: TranslationPaths, errorKey?: number): TranslationKeyErrors { + return {[errorKey ?? DateUtils.getMicroseconds()]: {translationKey}}; } /** @@ -201,17 +205,20 @@ function isReceiptError(message: unknown): message is ReceiptError { return ((message as Record)?.error ?? '') === CONST.IOU.RECEIPT_ERROR; } -function isTranslationPathError(message: unknown): message is TranslationPathError { +/** + * Check if the error includes a translation key. + */ +function isTranslationKeyError(message: unknown): message is TranslationKeyError { if (typeof message === 'string') { return false; } if (Array.isArray(message)) { return false; } - if (Object.keys(message as Record).length === 0) { + if (Object.keys(message as Record).length !== 1) { return false; } - return (message as Record)?.translationPath !== undefined; + return (message as Record)?.translationKey !== undefined; } export { @@ -229,7 +236,7 @@ export { getMicroSecondOnyxErrorWithMessage, getMicroSecondOnyxErrorObject, isReceiptError, - isTranslationPathError, + isTranslationKeyError, getMicroSecondTranslationErrorWithTranslationKey, }; diff --git a/tests/unit/ErrorUtilsTest.ts b/tests/unit/ErrorUtilsTest.ts index f5517c56156a..0f90a064fb35 100644 --- a/tests/unit/ErrorUtilsTest.ts +++ b/tests/unit/ErrorUtilsTest.ts @@ -1,6 +1,10 @@ import * as ErrorUtils from '@src/libs/ErrorUtils'; +import DateUtils from '@src/libs/DateUtils'; import type {Errors} from '@src/types/onyx/OnyxCommon'; +// Mock DateUtils +jest.mock('@src/libs/DateUtils'); + describe('ErrorUtils', () => { test('should add a new error message for a given inputID', () => { const errors: Errors = {}; @@ -62,4 +66,88 @@ describe('ErrorUtils', () => { username: 'Username cannot be empty\nUsername must be at least 6 characters long\nUsername must contain at least one letter\nUsername must not contain special characters', }); }); + + describe('getMicroSecondTranslationErrorWithTranslationKey', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + test('should create an error object with microsecond timestamp and translation key', () => { + const mockMicroseconds = 1234567890123; + (DateUtils.getMicroseconds as jest.Mock).mockReturnValue(mockMicroseconds); + + const result = ErrorUtils.getMicroSecondTranslationErrorWithTranslationKey('passwordForm.error.incorrectLoginOrPassword'); + + expect(result).toEqual({ + [mockMicroseconds]: {translationKey: 'passwordForm.error.incorrectLoginOrPassword'}, + }); + expect(DateUtils.getMicroseconds).toHaveBeenCalledTimes(1); + }); + + test('should use provided errorKey instead of generating microsecond timestamp', () => { + const customErrorKey = 9876543210; + const result = ErrorUtils.getMicroSecondTranslationErrorWithTranslationKey('passwordForm.error.fallback', customErrorKey); + + expect(result).toEqual({ + [customErrorKey]: {translationKey: 'passwordForm.error.fallback'}, + }); + expect(DateUtils.getMicroseconds).not.toHaveBeenCalled(); + }); + + test('should handle different translation keys', () => { + const mockMicroseconds = 1111111111111; + (DateUtils.getMicroseconds as jest.Mock).mockReturnValue(mockMicroseconds); + + const result = ErrorUtils.getMicroSecondTranslationErrorWithTranslationKey('passwordForm.error.incorrectLoginOrPassword'); + + expect(result).toEqual({ + [mockMicroseconds]: {translationKey: 'passwordForm.error.incorrectLoginOrPassword'}, + }); + }); + }); + + describe('isTranslationKeyError', () => { + test('should return false for string messages', () => { + expect(ErrorUtils.isTranslationKeyError('This is a string error')).toBe(false); + expect(ErrorUtils.isTranslationKeyError('')).toBe(false); + }); + + test('should return false for array messages', () => { + expect(ErrorUtils.isTranslationKeyError(['error1', 'error2'])).toBe(false); + expect(ErrorUtils.isTranslationKeyError([])).toBe(false); + }); + + test('should return false for empty objects', () => { + expect(ErrorUtils.isTranslationKeyError({})).toBe(false); + }); + + test('should return false for objects with multiple keys', () => { + expect(ErrorUtils.isTranslationKeyError({ + translationKey: 'passwordForm.error.fallback', + extraKey: 'extra', + })).toBe(false); + }); + + test('should return false for objects without translationKey property', () => { + expect(ErrorUtils.isTranslationKeyError({error: 'generic error'})).toBe(false); + expect(ErrorUtils.isTranslationKeyError({message: 'error message'})).toBe(false); + }); + + test('should return true for valid TranslationKeyError objects', () => { + expect(ErrorUtils.isTranslationKeyError({translationKey: 'passwordForm.error.fallback'})).toBe(true); + expect(ErrorUtils.isTranslationKeyError({translationKey: 'passwordForm.error.incorrectLoginOrPassword'})).toBe(true); + expect(ErrorUtils.isTranslationKeyError({translationKey: 'session.offlineMessageRetry'})).toBe(true); + }); + + test('should return false for null and undefined', () => { + expect(ErrorUtils.isTranslationKeyError(null)).toBe(false); + expect(ErrorUtils.isTranslationKeyError(undefined)).toBe(false); + }); + + test('should return false for primitive types', () => { + expect(ErrorUtils.isTranslationKeyError(123)).toBe(false); + expect(ErrorUtils.isTranslationKeyError(true)).toBe(false); + expect(ErrorUtils.isTranslationKeyError(false)).toBe(false); + }); + }); }); From 2130ed885dbccd0147b9d6159e2e468664d22ba1 Mon Sep 17 00:00:00 2001 From: drminh2807 Date: Thu, 17 Jul 2025 11:24:48 +0700 Subject: [PATCH 3/6] Add more unit tests --- src/components/MessagesRow.tsx | 2 +- src/components/OfflineWithFeedback.tsx | 2 +- src/languages/types.ts | 4 +- src/libs/ErrorUtils.ts | 3 + tests/unit/ErrorUtilsTest.ts | 83 ++++++++++++++++++++++++-- 5 files changed, 85 insertions(+), 9 deletions(-) diff --git a/src/components/MessagesRow.tsx b/src/components/MessagesRow.tsx index 7831304fccd5..796faf8c6492 100644 --- a/src/components/MessagesRow.tsx +++ b/src/components/MessagesRow.tsx @@ -5,9 +5,9 @@ import useLocalize from '@hooks/useLocalize'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import CONST from '@src/CONST'; +import type {TranslationKeyError} from '@src/languages/types'; import type {ReceiptError} from '@src/types/onyx/Transaction'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; -import type { TranslationKeyError } from '@src/languages/types'; import DotIndicatorMessage from './DotIndicatorMessage'; import Icon from './Icon'; import * as Expensicons from './Icon/Expensicons'; diff --git a/src/components/OfflineWithFeedback.tsx b/src/components/OfflineWithFeedback.tsx index 7a352aac64d8..7610fd41124b 100644 --- a/src/components/OfflineWithFeedback.tsx +++ b/src/components/OfflineWithFeedback.tsx @@ -8,11 +8,11 @@ import mapChildrenFlat from '@libs/mapChildrenFlat'; import shouldRenderOffscreen from '@libs/shouldRenderOffscreen'; import type {AllStyles} from '@styles/utils/types'; import CONST from '@src/CONST'; +import type {TranslationKeyErrors} from '@src/languages/types'; import type * as OnyxCommon from '@src/types/onyx/OnyxCommon'; import type {ReceiptErrors} from '@src/types/onyx/Transaction'; import type ChildrenProps from '@src/types/utils/ChildrenProps'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; -import type { TranslationKeyErrors } from '@src/languages/types'; import CustomStylesForChildrenProvider from './CustomStylesForChildrenProvider'; import ErrorMessageRow from './ErrorMessageRow'; import ImageSVG from './ImageSVG'; diff --git a/src/languages/types.ts b/src/languages/types.ts index d219fc40ce29..c089db5696a2 100644 --- a/src/languages/types.ts +++ b/src/languages/types.ts @@ -77,8 +77,8 @@ type DefaultTranslation = typeof en; type TranslationPaths = FlattenObject; type TranslationKeyError = { - translationKey: TranslationPaths -} + translationKey: TranslationPaths; +}; type TranslationKeyErrors = Record; /** diff --git a/src/libs/ErrorUtils.ts b/src/libs/ErrorUtils.ts index 27f38defeb8a..ed52c027686a 100644 --- a/src/libs/ErrorUtils.ts +++ b/src/libs/ErrorUtils.ts @@ -209,6 +209,9 @@ function isReceiptError(message: unknown): message is ReceiptError { * Check if the error includes a translation key. */ function isTranslationKeyError(message: unknown): message is TranslationKeyError { + if (!message) { + return false; + } if (typeof message === 'string') { return false; } diff --git a/tests/unit/ErrorUtilsTest.ts b/tests/unit/ErrorUtilsTest.ts index 0f90a064fb35..3006b283565d 100644 --- a/tests/unit/ErrorUtilsTest.ts +++ b/tests/unit/ErrorUtilsTest.ts @@ -1,5 +1,5 @@ -import * as ErrorUtils from '@src/libs/ErrorUtils'; import DateUtils from '@src/libs/DateUtils'; +import * as ErrorUtils from '@src/libs/ErrorUtils'; import type {Errors} from '@src/types/onyx/OnyxCommon'; // Mock DateUtils @@ -122,10 +122,12 @@ describe('ErrorUtils', () => { }); test('should return false for objects with multiple keys', () => { - expect(ErrorUtils.isTranslationKeyError({ - translationKey: 'passwordForm.error.fallback', - extraKey: 'extra', - })).toBe(false); + expect( + ErrorUtils.isTranslationKeyError({ + translationKey: 'passwordForm.error.fallback', + extraKey: 'extra', + }), + ).toBe(false); }); test('should return false for objects without translationKey property', () => { @@ -150,4 +152,75 @@ describe('ErrorUtils', () => { expect(ErrorUtils.isTranslationKeyError(false)).toBe(false); }); }); + + describe('Integration: getMicroSecondTranslationErrorWithTranslationKey and isTranslationKeyError', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + test('should create translation error objects that are correctly identified by isTranslationKeyError', () => { + const mockMicroseconds = 1234567890123; + (DateUtils.getMicroseconds as jest.Mock).mockReturnValue(mockMicroseconds); + + const errorObject = ErrorUtils.getMicroSecondTranslationErrorWithTranslationKey('passwordForm.error.incorrectLoginOrPassword'); + + // The error object should have one key (the timestamp) + const keys = Object.keys(errorObject); + expect(keys).toHaveLength(1); + + // The value at that key should be a valid translation key error + const errorValue = errorObject[keys[0]]; + expect(ErrorUtils.isTranslationKeyError(errorValue)).toBe(true); + }); + + test('should work with custom error keys', () => { + const customErrorKey = 9876543210; + const errorObject = ErrorUtils.getMicroSecondTranslationErrorWithTranslationKey('passwordForm.error.fallback', customErrorKey); + + // The error object should have the custom key + expect(errorObject).toHaveProperty(customErrorKey.toString()); + + // The value should be a valid translation key error + const errorValue = errorObject[customErrorKey]; + expect(ErrorUtils.isTranslationKeyError(errorValue)).toBe(true); + }); + + test('should create objects with multiple errors that are all valid translation key errors', () => { + const mockMicroseconds1 = 1111111111111; + const mockMicroseconds2 = 2222222222222; + + (DateUtils.getMicroseconds as jest.Mock).mockReturnValueOnce(mockMicroseconds1).mockReturnValueOnce(mockMicroseconds2); + + const error1 = ErrorUtils.getMicroSecondTranslationErrorWithTranslationKey('passwordForm.error.incorrectLoginOrPassword'); + const error2 = ErrorUtils.getMicroSecondTranslationErrorWithTranslationKey('session.offlineMessageRetry'); + + // Combine the error objects + const combinedErrors = {...error1, ...error2}; + + // All values should be valid translation key errors + Object.values(combinedErrors).forEach((errorValue) => { + expect(ErrorUtils.isTranslationKeyError(errorValue)).toBe(true); + }); + }); + + test('should verify the structure of created translation key errors', () => { + const mockMicroseconds = 5555555555555; + (DateUtils.getMicroseconds as jest.Mock).mockReturnValue(mockMicroseconds); + + const errorObject = ErrorUtils.getMicroSecondTranslationErrorWithTranslationKey('passwordForm.error.twoFactorAuthenticationEnabled'); + const errorValue = errorObject[mockMicroseconds]; + + // Verify the structure matches what isTranslationKeyError expects + expect(errorValue).toEqual({ + translationKey: 'passwordForm.error.twoFactorAuthenticationEnabled', + }); + + // Verify it passes all the checks in isTranslationKeyError + expect(typeof errorValue).not.toBe('string'); + expect(Array.isArray(errorValue)).toBe(false); + expect(Object.keys(errorValue)).toHaveLength(1); + expect(errorValue.translationKey).toBeDefined(); + expect(ErrorUtils.isTranslationKeyError(errorValue)).toBe(true); + }); + }); }); From 5650d3148fe4fbe86366e6af345b5ea13854e123 Mon Sep 17 00:00:00 2001 From: drminh2807 Date: Sat, 19 Jul 2025 08:10:57 +0700 Subject: [PATCH 4/6] chore: Move TranslationKeyError to OnyxCommon --- src/components/DotIndicatorMessage.tsx | 2 +- src/components/ErrorMessageRow.tsx | 5 ++--- src/components/MessagesRow.tsx | 2 +- src/components/OfflineWithFeedback.tsx | 3 +-- src/languages/types.ts | 7 +------ src/libs/ErrorUtils.ts | 4 ++-- src/types/onyx/OnyxCommon.ts | 18 +++++++++++++++++- 7 files changed, 25 insertions(+), 16 deletions(-) diff --git a/src/components/DotIndicatorMessage.tsx b/src/components/DotIndicatorMessage.tsx index 6b938b115d79..3eb110f2ad94 100644 --- a/src/components/DotIndicatorMessage.tsx +++ b/src/components/DotIndicatorMessage.tsx @@ -10,8 +10,8 @@ import useThemeStyles from '@hooks/useThemeStyles'; import {isReceiptError, isTranslationKeyError} from '@libs/ErrorUtils'; import fileDownload from '@libs/fileDownload'; import handleRetryPress from '@libs/ReceiptUploadRetryHandler'; -import type {TranslationKeyError} from '@src/languages/types'; import type {ReceiptError} from '@src/types/onyx/Transaction'; +import type {TranslationKeyError} from '@src/types/onyx/OnyxCommon'; import ConfirmModal from './ConfirmModal'; import Icon from './Icon'; import * as Expensicons from './Icon/Expensicons'; diff --git a/src/components/ErrorMessageRow.tsx b/src/components/ErrorMessageRow.tsx index 930cd33c1643..c468412e7fa3 100644 --- a/src/components/ErrorMessageRow.tsx +++ b/src/components/ErrorMessageRow.tsx @@ -1,7 +1,6 @@ import mapValues from 'lodash/mapValues'; import React from 'react'; import type {StyleProp, ViewStyle} from 'react-native'; -import type {TranslationKeyError, TranslationKeyErrors} from '@src/languages/types'; import type * as OnyxCommon from '@src/types/onyx/OnyxCommon'; import type {ReceiptError, ReceiptErrors} from '@src/types/onyx/Transaction'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; @@ -9,7 +8,7 @@ import MessagesRow from './MessagesRow'; type ErrorMessageRowProps = { /** The errors to display */ - errors?: OnyxCommon.Errors | ReceiptErrors | TranslationKeyErrors | null; + errors?: OnyxCommon.Errors | ReceiptErrors | OnyxCommon.TranslationKeyErrors | null; /** Additional style object for the error row */ errorRowStyles?: StyleProp; @@ -27,7 +26,7 @@ type ErrorMessageRowProps = { function ErrorMessageRow({errors, errorRowStyles, onClose, canDismissError = true, dismissError}: ErrorMessageRowProps) { // Some errors have a null message. This is used to apply opacity only and to avoid showing redundant messages. const errorEntries = Object.entries(errors ?? {}); - const filteredErrorEntries = errorEntries.filter((errorEntry): errorEntry is [string, string | ReceiptError | TranslationKeyError] => errorEntry[1] !== null); + const filteredErrorEntries = errorEntries.filter((errorEntry): errorEntry is [string, string | ReceiptError | OnyxCommon.TranslationKeyError] => errorEntry[1] !== null); const errorMessages = mapValues(Object.fromEntries(filteredErrorEntries), (error) => error); const hasErrorMessages = !isEmptyObject(errorMessages); diff --git a/src/components/MessagesRow.tsx b/src/components/MessagesRow.tsx index 796faf8c6492..83f6875d58a2 100644 --- a/src/components/MessagesRow.tsx +++ b/src/components/MessagesRow.tsx @@ -5,9 +5,9 @@ import useLocalize from '@hooks/useLocalize'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import CONST from '@src/CONST'; -import type {TranslationKeyError} from '@src/languages/types'; import type {ReceiptError} from '@src/types/onyx/Transaction'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; +import type { TranslationKeyError } from '@src/types/onyx/OnyxCommon'; import DotIndicatorMessage from './DotIndicatorMessage'; import Icon from './Icon'; import * as Expensicons from './Icon/Expensicons'; diff --git a/src/components/OfflineWithFeedback.tsx b/src/components/OfflineWithFeedback.tsx index 7610fd41124b..b86b666c2d97 100644 --- a/src/components/OfflineWithFeedback.tsx +++ b/src/components/OfflineWithFeedback.tsx @@ -8,7 +8,6 @@ import mapChildrenFlat from '@libs/mapChildrenFlat'; import shouldRenderOffscreen from '@libs/shouldRenderOffscreen'; import type {AllStyles} from '@styles/utils/types'; import CONST from '@src/CONST'; -import type {TranslationKeyErrors} from '@src/languages/types'; import type * as OnyxCommon from '@src/types/onyx/OnyxCommon'; import type {ReceiptErrors} from '@src/types/onyx/Transaction'; import type ChildrenProps from '@src/types/utils/ChildrenProps'; @@ -31,7 +30,7 @@ type OfflineWithFeedbackProps = ChildrenProps & { shouldHideOnDelete?: boolean; /** The errors to display */ - errors?: OnyxCommon.Errors | ReceiptErrors | TranslationKeyErrors | null; + errors?: OnyxCommon.Errors | OnyxCommon.TranslationKeyErrors | ReceiptErrors | null; /** Whether we should show the error messages */ shouldShowErrorMessages?: boolean; diff --git a/src/languages/types.ts b/src/languages/types.ts index c089db5696a2..abcf0086e4ee 100644 --- a/src/languages/types.ts +++ b/src/languages/types.ts @@ -76,11 +76,6 @@ type DefaultTranslation = typeof en; */ type TranslationPaths = FlattenObject; -type TranslationKeyError = { - translationKey: TranslationPaths; -}; - -type TranslationKeyErrors = Record; /** * Flattened default translation object with its values */ @@ -99,4 +94,4 @@ type TranslationParameters = FlatTranslationsObje : Args : never[]; -export type {TranslationDeepObject, TranslationPaths, PluralForm, FlatTranslationsObject, TranslationParameters, TranslationKeyError, TranslationKeyErrors}; +export type {TranslationDeepObject, TranslationPaths, PluralForm, FlatTranslationsObject, TranslationParameters}; diff --git a/src/libs/ErrorUtils.ts b/src/libs/ErrorUtils.ts index ed52c027686a..14b4c11d2cbd 100644 --- a/src/libs/ErrorUtils.ts +++ b/src/libs/ErrorUtils.ts @@ -1,8 +1,8 @@ import mapValues from 'lodash/mapValues'; import type {OnyxEntry} from 'react-native-onyx'; import CONST from '@src/CONST'; -import type {TranslationKeyError, TranslationKeyErrors, TranslationPaths} from '@src/languages/types'; -import type {ErrorFields, Errors} from '@src/types/onyx/OnyxCommon'; +import type {TranslationPaths} from '@src/languages/types'; +import type {ErrorFields, Errors, TranslationKeyError, TranslationKeyErrors} from '@src/types/onyx/OnyxCommon'; import type Response from '@src/types/onyx/Response'; import type {ReceiptError} from '@src/types/onyx/Transaction'; import DateUtils from './DateUtils'; diff --git a/src/types/onyx/OnyxCommon.ts b/src/types/onyx/OnyxCommon.ts index 4f97df07ea26..b117fa0d74cb 100644 --- a/src/types/onyx/OnyxCommon.ts +++ b/src/types/onyx/OnyxCommon.ts @@ -1,6 +1,7 @@ import type {ValueOf} from 'type-fest'; import type {AvatarSource} from '@libs/UserUtils'; import type CONST from '@src/CONST'; +import type { TranslationPaths } from '@src/languages/types'; /** Pending onyx actions */ type PendingAction = ValueOf | null; @@ -26,6 +27,21 @@ type ErrorFields = Record; +/** + * Error object for a translation key + */ +type TranslationKeyError = { + /** + * The translation key + */ + translationKey: TranslationPaths; +}; + +/** + * Mapping of form fields with key and translation key error variables + */ +type TranslationKeyErrors = Record; + /** * Types of avatars ** avatar - user avatar @@ -54,4 +70,4 @@ type Icon = { fill?: string; }; -export type {Icon, PendingAction, PendingFields, ErrorFields, Errors, AvatarType, OnyxValueWithOfflineFeedback}; +export type {Icon, PendingAction, PendingFields, ErrorFields, Errors, AvatarType, OnyxValueWithOfflineFeedback, TranslationKeyError, TranslationKeyErrors}; From 54fa5af6e9b267390d185c21404d539cbf674db4 Mon Sep 17 00:00:00 2001 From: drminh2807 Date: Sat, 19 Jul 2025 08:15:03 +0700 Subject: [PATCH 5/6] fix prettier --- src/components/DotIndicatorMessage.tsx | 2 +- src/components/MessagesRow.tsx | 2 +- src/types/onyx/OnyxCommon.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/components/DotIndicatorMessage.tsx b/src/components/DotIndicatorMessage.tsx index 3eb110f2ad94..4b1d975b014b 100644 --- a/src/components/DotIndicatorMessage.tsx +++ b/src/components/DotIndicatorMessage.tsx @@ -10,8 +10,8 @@ import useThemeStyles from '@hooks/useThemeStyles'; import {isReceiptError, isTranslationKeyError} from '@libs/ErrorUtils'; import fileDownload from '@libs/fileDownload'; import handleRetryPress from '@libs/ReceiptUploadRetryHandler'; -import type {ReceiptError} from '@src/types/onyx/Transaction'; import type {TranslationKeyError} from '@src/types/onyx/OnyxCommon'; +import type {ReceiptError} from '@src/types/onyx/Transaction'; import ConfirmModal from './ConfirmModal'; import Icon from './Icon'; import * as Expensicons from './Icon/Expensicons'; diff --git a/src/components/MessagesRow.tsx b/src/components/MessagesRow.tsx index 83f6875d58a2..487ab72b7da2 100644 --- a/src/components/MessagesRow.tsx +++ b/src/components/MessagesRow.tsx @@ -5,9 +5,9 @@ import useLocalize from '@hooks/useLocalize'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import CONST from '@src/CONST'; +import type {TranslationKeyError} from '@src/types/onyx/OnyxCommon'; import type {ReceiptError} from '@src/types/onyx/Transaction'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; -import type { TranslationKeyError } from '@src/types/onyx/OnyxCommon'; import DotIndicatorMessage from './DotIndicatorMessage'; import Icon from './Icon'; import * as Expensicons from './Icon/Expensicons'; diff --git a/src/types/onyx/OnyxCommon.ts b/src/types/onyx/OnyxCommon.ts index b117fa0d74cb..d4298ff4a2f9 100644 --- a/src/types/onyx/OnyxCommon.ts +++ b/src/types/onyx/OnyxCommon.ts @@ -1,7 +1,7 @@ import type {ValueOf} from 'type-fest'; import type {AvatarSource} from '@libs/UserUtils'; import type CONST from '@src/CONST'; -import type { TranslationPaths } from '@src/languages/types'; +import type {TranslationPaths} from '@src/languages/types'; /** Pending onyx actions */ type PendingAction = ValueOf | null; From 47c09d557d86117f8623dbb4db32c7c340c00854 Mon Sep 17 00:00:00 2001 From: drminh2807 Date: Tue, 5 Aug 2025 09:51:43 +0700 Subject: [PATCH 6/6] Refactor isTranslationKeyError function for improved readability by consolidating conditional checks. --- src/libs/ErrorUtils.ts | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/src/libs/ErrorUtils.ts b/src/libs/ErrorUtils.ts index 14b4c11d2cbd..7fcc9546a382 100644 --- a/src/libs/ErrorUtils.ts +++ b/src/libs/ErrorUtils.ts @@ -209,13 +209,7 @@ function isReceiptError(message: unknown): message is ReceiptError { * Check if the error includes a translation key. */ function isTranslationKeyError(message: unknown): message is TranslationKeyError { - if (!message) { - return false; - } - if (typeof message === 'string') { - return false; - } - if (Array.isArray(message)) { + if (!message || typeof message === 'string' || Array.isArray(message)) { return false; } if (Object.keys(message as Record).length !== 1) {