Skip to content
7 changes: 4 additions & 3 deletions src/components/DotIndicatorMessage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -25,7 +26,7 @@ type DotIndicatorMessageProps = {
* timestamp: 'message',
* }
*/
messages: Record<string, string | ReceiptError | ReactElement | null>;
messages: Record<string, string | ReceiptError | TranslationKeyError | ReactElement | null>;

/** The type of message, 'error' shows a red dot, 'success' shows a green dot */
type: 'error' | 'success';
Expand Down Expand Up @@ -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}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should use Str.htmlDecode to ensure the message is properly decoded. Fixed here.

</Text>
);
};
Expand Down
4 changes: 2 additions & 2 deletions src/components/ErrorMessageRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<ViewStyle>;
Expand All @@ -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);

Expand Down
3 changes: 2 additions & 1 deletion src/components/MessagesRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -15,7 +16,7 @@ import Tooltip from './Tooltip';

type MessagesRowProps = {
/** The messages to display */
messages: Record<string, string | ReceiptError>;
messages: Record<string, string | ReceiptError | TranslationKeyError>;

/** The type of message, 'error' shows a red dot, 'success' shows a green dot */
type: 'error' | 'success';
Expand Down
2 changes: 1 addition & 1 deletion src/components/OfflineWithFeedback.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
29 changes: 26 additions & 3 deletions src/libs/ErrorUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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}};
}

/**
Expand Down Expand Up @@ -197,6 +205,19 @@ function isReceiptError(message: unknown): message is ReceiptError {
return ((message as Record<string, unknown>)?.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<string, unknown>).length !== 1) {
return false;
}
return (message as Record<string, unknown>)?.translationKey !== undefined;
}

export {
addErrorMessage,
getAuthenticateErrorMessage,
Expand All @@ -212,6 +233,8 @@ export {
getMicroSecondOnyxErrorWithMessage,
getMicroSecondOnyxErrorObject,
isReceiptError,
isTranslationKeyError,
getMicroSecondTranslationErrorWithTranslationKey,
};

export type {OnyxDataWithErrors};
4 changes: 2 additions & 2 deletions src/libs/actions/Report.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@
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';
Expand Down Expand Up @@ -268,7 +268,7 @@
let currentUserAccountID = -1;
let currentUserEmail: string | undefined;

Onyx.connect({

Check warning on line 271 in src/libs/actions/Report.ts

View workflow job for this annotation

GitHub Actions / Changed files ESLint check

Onyx.connect() is deprecated. Use useOnyx() hook instead and pass the data as parameters to a pure function
key: ONYXKEYS.SESSION,
callback: (value) => {
// When signed out, val is undefined
Expand All @@ -281,13 +281,13 @@
},
});

Onyx.connect({

Check warning on line 284 in src/libs/actions/Report.ts

View workflow job for this annotation

GitHub Actions / Changed files ESLint check

Onyx.connect() is deprecated. Use useOnyx() hook instead and pass the data as parameters to a pure function
key: ONYXKEYS.CONCIERGE_REPORT_ID,
callback: (value) => (conciergeReportID = value),
});

let preferredSkinTone: number = CONST.EMOJI_DEFAULT_SKIN_TONE;
Onyx.connect({

Check warning on line 290 in src/libs/actions/Report.ts

View workflow job for this annotation

GitHub Actions / Changed files ESLint check

Onyx.connect() is deprecated. Use useOnyx() hook instead and pass the data as parameters to a pure function
key: ONYXKEYS.PREFERRED_EMOJI_SKIN_TONE,
callback: (value) => {
preferredSkinTone = EmojiUtils.getPreferredSkinToneIndex(value);
Expand All @@ -297,7 +297,7 @@
// map of reportID to all reportActions for that report
const allReportActions: OnyxCollection<ReportActions> = {};

Onyx.connect({

Check warning on line 300 in src/libs/actions/Report.ts

View workflow job for this annotation

GitHub Actions / Changed files ESLint check

Onyx.connect() is deprecated. Use useOnyx() hook instead and pass the data as parameters to a pure function
key: ONYXKEYS.COLLECTION.REPORT_ACTIONS,
callback: (actions, key) => {
if (!key || !actions) {
Expand All @@ -309,14 +309,14 @@
});

let allTransactionViolations: OnyxCollection<TransactionViolations> = {};
Onyx.connect({

Check warning on line 312 in src/libs/actions/Report.ts

View workflow job for this annotation

GitHub Actions / Changed files ESLint check

Onyx.connect() is deprecated. Use useOnyx() hook instead and pass the data as parameters to a pure function
key: ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS,
waitForCollectionCallback: true,
callback: (value) => (allTransactionViolations = value),
});

let allReports: OnyxCollection<Report>;
Onyx.connect({

Check warning on line 319 in src/libs/actions/Report.ts

View workflow job for this annotation

GitHub Actions / Changed files ESLint check

Onyx.connect() is deprecated. Use useOnyx() hook instead and pass the data as parameters to a pure function
key: ONYXKEYS.COLLECTION.REPORT,
waitForCollectionCallback: true,
callback: (value) => {
Expand All @@ -326,7 +326,7 @@

let isNetworkOffline = false;
let networkStatus: NetworkStatus;
Onyx.connect({

Check warning on line 329 in src/libs/actions/Report.ts

View workflow job for this annotation

GitHub Actions / Changed files ESLint check

Onyx.connect() is deprecated. Use useOnyx() hook instead and pass the data as parameters to a pure function
key: ONYXKEYS.NETWORK,
callback: (value) => {
isNetworkOffline = value?.isOffline ?? false;
Expand All @@ -335,7 +335,7 @@
});

let allPersonalDetails: OnyxEntry<PersonalDetailsList> = {};
Onyx.connect({

Check warning on line 338 in src/libs/actions/Report.ts

View workflow job for this annotation

GitHub Actions / Changed files ESLint check

Onyx.connect() is deprecated. Use useOnyx() hook instead and pass the data as parameters to a pure function
key: ONYXKEYS.PERSONAL_DETAILS_LIST,
callback: (value) => {
allPersonalDetails = value ?? {};
Expand All @@ -343,7 +343,7 @@
});

let account: OnyxEntry<Account> = {};
Onyx.connect({

Check warning on line 346 in src/libs/actions/Report.ts

View workflow job for this annotation

GitHub Actions / Changed files ESLint check

Onyx.connect() is deprecated. Use useOnyx() hook instead and pass the data as parameters to a pure function
key: ONYXKEYS.ACCOUNT,
callback: (value) => {
account = value ?? {};
Expand All @@ -351,7 +351,7 @@
});

const draftNoteMap: OnyxCollection<string> = {};
Onyx.connect({

Check warning on line 354 in src/libs/actions/Report.ts

View workflow job for this annotation

GitHub Actions / Changed files ESLint check

Onyx.connect() is deprecated. Use useOnyx() hook instead and pass the data as parameters to a pure function
key: ONYXKEYS.COLLECTION.PRIVATE_NOTES_DRAFT,
callback: (value, key) => {
if (!key) {
Expand Down Expand Up @@ -5723,7 +5723,7 @@
key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`,
value: {
[optimisticMovedReportAction.reportActionID]: {
errors: getMicroSecondOnyxErrorWithTranslationKey('common.genericErrorMessage'),
errors: getMicroSecondTranslationErrorWithTranslationKey('common.genericErrorMessage'),
},
},
});
Expand Down
18 changes: 17 additions & 1 deletion src/types/onyx/OnyxCommon.ts
Original file line number Diff line number Diff line change
@@ -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<typeof CONST.RED_BRICK_ROAD_PENDING_ACTION> | null;
Expand All @@ -26,6 +27,21 @@ type ErrorFields<TKey extends string = string> = Record<TKey, Errors | null | un
/** Mapping of form fields with error translation keys and variables */
type Errors = Record<string, string | null>;

/**
* 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<string, TranslationKeyError>;

/**
* Types of avatars
** avatar - user avatar
Expand Down Expand Up @@ -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};
161 changes: 161 additions & 0 deletions tests/unit/ErrorUtilsTest.ts
Original file line number Diff line number Diff line change
@@ -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 = {};
Expand Down Expand Up @@ -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);
});
});
});
Loading