diff --git a/src/components/DotIndicatorMessage.tsx b/src/components/DotIndicatorMessage.tsx index dd270f5e6ca6..4b1d975b014b 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, isTranslationKeyError} from '@libs/ErrorUtils'; import fileDownload from '@libs/fileDownload'; import handleRetryPress from '@libs/ReceiptUploadRetryHandler'; +import type {TranslationKeyError} from '@src/types/onyx/OnyxCommon'; 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} + {isTranslationKeyError(message) ? translate(message.translationKey) : message} ); }; diff --git a/src/components/ErrorMessageRow.tsx b/src/components/ErrorMessageRow.tsx index 379e5f903313..c468412e7fa3 100644 --- a/src/components/ErrorMessageRow.tsx +++ b/src/components/ErrorMessageRow.tsx @@ -8,7 +8,7 @@ import MessagesRow from './MessagesRow'; type ErrorMessageRowProps = { /** The errors to display */ - errors?: OnyxCommon.Errors | ReceiptErrors | null; + errors?: OnyxCommon.Errors | ReceiptErrors | OnyxCommon.TranslationKeyErrors | null; /** Additional style object for the error row */ errorRowStyles?: StyleProp; @@ -26,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] => 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 f61dbc39adaa..487ab72b7da2 100644 --- a/src/components/MessagesRow.tsx +++ b/src/components/MessagesRow.tsx @@ -5,6 +5,7 @@ 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 DotIndicatorMessage from './DotIndicatorMessage'; @@ -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 6a433a52dbcd..b86b666c2d97 100644 --- a/src/components/OfflineWithFeedback.tsx +++ b/src/components/OfflineWithFeedback.tsx @@ -30,7 +30,7 @@ type OfflineWithFeedbackProps = ChildrenProps & { shouldHideOnDelete?: boolean; /** The errors to display */ - errors?: OnyxCommon.Errors | ReceiptErrors | null; + errors?: OnyxCommon.Errors | OnyxCommon.TranslationKeyErrors | ReceiptErrors | null; /** Whether we should show the error messages */ shouldShowErrorMessages?: boolean; diff --git a/src/libs/ErrorUtils.ts b/src/libs/ErrorUtils.ts index b880239b8abf..7fcc9546a382 100644 --- a/src/libs/ErrorUtils.ts +++ b/src/libs/ErrorUtils.ts @@ -2,11 +2,11 @@ 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 {ErrorFields, Errors} from '@src/types/onyx/OnyxCommon'; +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'; -import * as Localize from './Localize'; +import {translateLocal} from './Localize'; function getAuthenticateErrorMessage(response: Response): TranslationPaths { switch (response.jsonCode) { @@ -42,7 +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)}; +} + +/** + * 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}}; } /** @@ -197,6 +205,19 @@ function isReceiptError(message: unknown): message is ReceiptError { return ((message as Record)?.error ?? '') === CONST.IOU.RECEIPT_ERROR; } +/** + * Check if the error includes a translation key. + */ +function isTranslationKeyError(message: unknown): message is TranslationKeyError { + if (!message || typeof message === 'string' || Array.isArray(message)) { + return false; + } + if (Object.keys(message as Record).length !== 1) { + return false; + } + return (message as Record)?.translationKey !== undefined; +} + export { addErrorMessage, getAuthenticateErrorMessage, @@ -212,6 +233,8 @@ export { getMicroSecondOnyxErrorWithMessage, getMicroSecondOnyxErrorObject, isReceiptError, + isTranslationKeyError, + getMicroSecondTranslationErrorWithTranslationKey, }; export type {OnyxDataWithErrors}; diff --git a/src/libs/actions/Report.ts b/src/libs/actions/Report.ts index 818ccbd09893..838ed7109e6c 100644 --- a/src/libs/actions/Report.ts +++ b/src/libs/actions/Report.ts @@ -68,7 +68,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 HttpUtils from '@libs/HttpUtils'; import isPublicScreenRoute from '@libs/isPublicScreenRoute'; @@ -5723,7 +5723,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'), }, }, }); diff --git a/src/types/onyx/OnyxCommon.ts b/src/types/onyx/OnyxCommon.ts index 4f97df07ea26..d4298ff4a2f9 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}; diff --git a/tests/unit/ErrorUtilsTest.ts b/tests/unit/ErrorUtilsTest.ts index f5517c56156a..3006b283565d 100644 --- a/tests/unit/ErrorUtilsTest.ts +++ b/tests/unit/ErrorUtilsTest.ts @@ -1,6 +1,10 @@ +import DateUtils from '@src/libs/DateUtils'; import * as ErrorUtils from '@src/libs/ErrorUtils'; 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,161 @@ 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); + }); + }); + + 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); + }); + }); });