Skip to content

Commit c08a31b

Browse files
authored
Merge pull request Expensify#65531 from drminh2807/fix/63962
fix: Error message is not translated to Spanish after trying to move …
2 parents 3921a4e + 0d4ea68 commit c08a31b

8 files changed

Lines changed: 215 additions & 13 deletions

File tree

src/components/DotIndicatorMessage.tsx

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,10 @@ import useLocalize from '@hooks/useLocalize';
77
import useStyleUtils from '@hooks/useStyleUtils';
88
import useTheme from '@hooks/useTheme';
99
import useThemeStyles from '@hooks/useThemeStyles';
10-
import {isReceiptError} from '@libs/ErrorUtils';
10+
import {isReceiptError, isTranslationKeyError} from '@libs/ErrorUtils';
1111
import fileDownload from '@libs/fileDownload';
1212
import handleRetryPress from '@libs/ReceiptUploadRetryHandler';
13+
import type {TranslationKeyError} from '@src/types/onyx/OnyxCommon';
1314
import type {ReceiptError} from '@src/types/onyx/Transaction';
1415
import ConfirmModal from './ConfirmModal';
1516
import Icon from './Icon';
@@ -25,7 +26,7 @@ type DotIndicatorMessageProps = {
2526
* timestamp: 'message',
2627
* }
2728
*/
28-
messages: Record<string, string | ReceiptError | ReactElement | null>;
29+
messages: Record<string, string | ReceiptError | TranslationKeyError | ReactElement | null>;
2930

3031
/** The type of message, 'error' shows a red dot, 'success' shows a green dot */
3132
type: 'error' | 'success';
@@ -109,7 +110,7 @@ function DotIndicatorMessage({messages = {}, style, type, textStyles, dismissErr
109110
key={index}
110111
style={[StyleUtils.getDotIndicatorTextStyles(isErrorMessage), textStyles]}
111112
>
112-
{message}
113+
{isTranslationKeyError(message) ? translate(message.translationKey) : message}
113114
</Text>
114115
);
115116
};

src/components/ErrorMessageRow.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import MessagesRow from './MessagesRow';
88

99
type ErrorMessageRowProps = {
1010
/** The errors to display */
11-
errors?: OnyxCommon.Errors | ReceiptErrors | null;
11+
errors?: OnyxCommon.Errors | ReceiptErrors | OnyxCommon.TranslationKeyErrors | null;
1212

1313
/** Additional style object for the error row */
1414
errorRowStyles?: StyleProp<ViewStyle>;
@@ -26,7 +26,7 @@ type ErrorMessageRowProps = {
2626
function ErrorMessageRow({errors, errorRowStyles, onClose, canDismissError = true, dismissError}: ErrorMessageRowProps) {
2727
// Some errors have a null message. This is used to apply opacity only and to avoid showing redundant messages.
2828
const errorEntries = Object.entries(errors ?? {});
29-
const filteredErrorEntries = errorEntries.filter((errorEntry): errorEntry is [string, string | ReceiptError] => errorEntry[1] !== null);
29+
const filteredErrorEntries = errorEntries.filter((errorEntry): errorEntry is [string, string | ReceiptError | OnyxCommon.TranslationKeyError] => errorEntry[1] !== null);
3030
const errorMessages = mapValues(Object.fromEntries(filteredErrorEntries), (error) => error);
3131
const hasErrorMessages = !isEmptyObject(errorMessages);
3232

src/components/MessagesRow.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import useLocalize from '@hooks/useLocalize';
55
import useTheme from '@hooks/useTheme';
66
import useThemeStyles from '@hooks/useThemeStyles';
77
import CONST from '@src/CONST';
8+
import type {TranslationKeyError} from '@src/types/onyx/OnyxCommon';
89
import type {ReceiptError} from '@src/types/onyx/Transaction';
910
import {isEmptyObject} from '@src/types/utils/EmptyObject';
1011
import DotIndicatorMessage from './DotIndicatorMessage';
@@ -15,7 +16,7 @@ import Tooltip from './Tooltip';
1516

1617
type MessagesRowProps = {
1718
/** The messages to display */
18-
messages: Record<string, string | ReceiptError>;
19+
messages: Record<string, string | ReceiptError | TranslationKeyError>;
1920

2021
/** The type of message, 'error' shows a red dot, 'success' shows a green dot */
2122
type: 'error' | 'success';

src/components/OfflineWithFeedback.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ type OfflineWithFeedbackProps = ChildrenProps & {
3030
shouldHideOnDelete?: boolean;
3131

3232
/** The errors to display */
33-
errors?: OnyxCommon.Errors | ReceiptErrors | null;
33+
errors?: OnyxCommon.Errors | OnyxCommon.TranslationKeyErrors | ReceiptErrors | null;
3434

3535
/** Whether we should show the error messages */
3636
shouldShowErrorMessages?: boolean;

src/libs/ErrorUtils.ts

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,11 @@ import mapValues from 'lodash/mapValues';
22
import type {OnyxEntry} from 'react-native-onyx';
33
import CONST from '@src/CONST';
44
import type {TranslationPaths} from '@src/languages/types';
5-
import type {ErrorFields, Errors} from '@src/types/onyx/OnyxCommon';
5+
import type {ErrorFields, Errors, TranslationKeyError, TranslationKeyErrors} from '@src/types/onyx/OnyxCommon';
66
import type Response from '@src/types/onyx/Response';
77
import type {ReceiptError} from '@src/types/onyx/Transaction';
88
import DateUtils from './DateUtils';
9-
import * as Localize from './Localize';
9+
import {translateLocal} from './Localize';
1010

1111
function getAuthenticateErrorMessage(response: Response): TranslationPaths {
1212
switch (response.jsonCode) {
@@ -42,7 +42,15 @@ function getAuthenticateErrorMessage(response: Response): TranslationPaths {
4242
* @param error - The translation key for the error message.
4343
*/
4444
function getMicroSecondOnyxErrorWithTranslationKey(error: TranslationPaths, errorKey?: number): Errors {
45-
return {[errorKey ?? DateUtils.getMicroseconds()]: Localize.translateLocal(error)};
45+
return {[errorKey ?? DateUtils.getMicroseconds()]: translateLocal(error)};
46+
}
47+
48+
/**
49+
* Creates an error object with a timestamp (in microseconds) as the key and the translation key as the value.
50+
* @param translationKey - The translation key for the error message.
51+
*/
52+
function getMicroSecondTranslationErrorWithTranslationKey(translationKey: TranslationPaths, errorKey?: number): TranslationKeyErrors {
53+
return {[errorKey ?? DateUtils.getMicroseconds()]: {translationKey}};
4654
}
4755

4856
/**
@@ -197,6 +205,19 @@ function isReceiptError(message: unknown): message is ReceiptError {
197205
return ((message as Record<string, unknown>)?.error ?? '') === CONST.IOU.RECEIPT_ERROR;
198206
}
199207

208+
/**
209+
* Check if the error includes a translation key.
210+
*/
211+
function isTranslationKeyError(message: unknown): message is TranslationKeyError {
212+
if (!message || typeof message === 'string' || Array.isArray(message)) {
213+
return false;
214+
}
215+
if (Object.keys(message as Record<string, unknown>).length !== 1) {
216+
return false;
217+
}
218+
return (message as Record<string, unknown>)?.translationKey !== undefined;
219+
}
220+
200221
export {
201222
addErrorMessage,
202223
getAuthenticateErrorMessage,
@@ -212,6 +233,8 @@ export {
212233
getMicroSecondOnyxErrorWithMessage,
213234
getMicroSecondOnyxErrorObject,
214235
isReceiptError,
236+
isTranslationKeyError,
237+
getMicroSecondTranslationErrorWithTranslationKey,
215238
};
216239

217240
export type {OnyxDataWithErrors};

src/libs/actions/Report.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@ import * as Environment from '@libs/Environment/Environment';
6868
import {getOldDotURLFromEnvironment} from '@libs/Environment/Environment';
6969
import getEnvironment from '@libs/Environment/getEnvironment';
7070
import type EnvironmentType from '@libs/Environment/getEnvironment/types';
71-
import {getMicroSecondOnyxErrorWithTranslationKey} from '@libs/ErrorUtils';
71+
import {getMicroSecondOnyxErrorWithTranslationKey, getMicroSecondTranslationErrorWithTranslationKey} from '@libs/ErrorUtils';
7272
import fileDownload from '@libs/fileDownload';
7373
import HttpUtils from '@libs/HttpUtils';
7474
import isPublicScreenRoute from '@libs/isPublicScreenRoute';
@@ -5761,7 +5761,7 @@ function buildOptimisticChangePolicyData(report: Report, policyID: string, repor
57615761
key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`,
57625762
value: {
57635763
[optimisticMovedReportAction.reportActionID]: {
5764-
errors: getMicroSecondOnyxErrorWithTranslationKey('common.genericErrorMessage'),
5764+
errors: getMicroSecondTranslationErrorWithTranslationKey('common.genericErrorMessage'),
57655765
},
57665766
},
57675767
});

src/types/onyx/OnyxCommon.ts

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import type {ValueOf} from 'type-fest';
22
import type {AvatarSource} from '@libs/UserUtils';
33
import type CONST from '@src/CONST';
4+
import type {TranslationPaths} from '@src/languages/types';
45

56
/** Pending onyx actions */
67
type PendingAction = ValueOf<typeof CONST.RED_BRICK_ROAD_PENDING_ACTION> | null;
@@ -26,6 +27,21 @@ type ErrorFields<TKey extends string = string> = Record<TKey, Errors | null | un
2627
/** Mapping of form fields with error translation keys and variables */
2728
type Errors = Record<string, string | null>;
2829

30+
/**
31+
* Error object for a translation key
32+
*/
33+
type TranslationKeyError = {
34+
/**
35+
* The translation key
36+
*/
37+
translationKey: TranslationPaths;
38+
};
39+
40+
/**
41+
* Mapping of form fields with key and translation key error variables
42+
*/
43+
type TranslationKeyErrors = Record<string, TranslationKeyError>;
44+
2945
/**
3046
* Types of avatars
3147
** avatar - user avatar
@@ -54,4 +70,4 @@ type Icon = {
5470
fill?: string;
5571
};
5672

57-
export type {Icon, PendingAction, PendingFields, ErrorFields, Errors, AvatarType, OnyxValueWithOfflineFeedback};
73+
export type {Icon, PendingAction, PendingFields, ErrorFields, Errors, AvatarType, OnyxValueWithOfflineFeedback, TranslationKeyError, TranslationKeyErrors};

tests/unit/ErrorUtilsTest.ts

Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
1+
import DateUtils from '@src/libs/DateUtils';
12
import * as ErrorUtils from '@src/libs/ErrorUtils';
23
import type {Errors} from '@src/types/onyx/OnyxCommon';
34

5+
// Mock DateUtils
6+
jest.mock('@src/libs/DateUtils');
7+
48
describe('ErrorUtils', () => {
59
test('should add a new error message for a given inputID', () => {
610
const errors: Errors = {};
@@ -62,4 +66,161 @@ describe('ErrorUtils', () => {
6266
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',
6367
});
6468
});
69+
70+
describe('getMicroSecondTranslationErrorWithTranslationKey', () => {
71+
beforeEach(() => {
72+
jest.clearAllMocks();
73+
});
74+
75+
test('should create an error object with microsecond timestamp and translation key', () => {
76+
const mockMicroseconds = 1234567890123;
77+
(DateUtils.getMicroseconds as jest.Mock).mockReturnValue(mockMicroseconds);
78+
79+
const result = ErrorUtils.getMicroSecondTranslationErrorWithTranslationKey('passwordForm.error.incorrectLoginOrPassword');
80+
81+
expect(result).toEqual({
82+
[mockMicroseconds]: {translationKey: 'passwordForm.error.incorrectLoginOrPassword'},
83+
});
84+
expect(DateUtils.getMicroseconds).toHaveBeenCalledTimes(1);
85+
});
86+
87+
test('should use provided errorKey instead of generating microsecond timestamp', () => {
88+
const customErrorKey = 9876543210;
89+
const result = ErrorUtils.getMicroSecondTranslationErrorWithTranslationKey('passwordForm.error.fallback', customErrorKey);
90+
91+
expect(result).toEqual({
92+
[customErrorKey]: {translationKey: 'passwordForm.error.fallback'},
93+
});
94+
expect(DateUtils.getMicroseconds).not.toHaveBeenCalled();
95+
});
96+
97+
test('should handle different translation keys', () => {
98+
const mockMicroseconds = 1111111111111;
99+
(DateUtils.getMicroseconds as jest.Mock).mockReturnValue(mockMicroseconds);
100+
101+
const result = ErrorUtils.getMicroSecondTranslationErrorWithTranslationKey('passwordForm.error.incorrectLoginOrPassword');
102+
103+
expect(result).toEqual({
104+
[mockMicroseconds]: {translationKey: 'passwordForm.error.incorrectLoginOrPassword'},
105+
});
106+
});
107+
});
108+
109+
describe('isTranslationKeyError', () => {
110+
test('should return false for string messages', () => {
111+
expect(ErrorUtils.isTranslationKeyError('This is a string error')).toBe(false);
112+
expect(ErrorUtils.isTranslationKeyError('')).toBe(false);
113+
});
114+
115+
test('should return false for array messages', () => {
116+
expect(ErrorUtils.isTranslationKeyError(['error1', 'error2'])).toBe(false);
117+
expect(ErrorUtils.isTranslationKeyError([])).toBe(false);
118+
});
119+
120+
test('should return false for empty objects', () => {
121+
expect(ErrorUtils.isTranslationKeyError({})).toBe(false);
122+
});
123+
124+
test('should return false for objects with multiple keys', () => {
125+
expect(
126+
ErrorUtils.isTranslationKeyError({
127+
translationKey: 'passwordForm.error.fallback',
128+
extraKey: 'extra',
129+
}),
130+
).toBe(false);
131+
});
132+
133+
test('should return false for objects without translationKey property', () => {
134+
expect(ErrorUtils.isTranslationKeyError({error: 'generic error'})).toBe(false);
135+
expect(ErrorUtils.isTranslationKeyError({message: 'error message'})).toBe(false);
136+
});
137+
138+
test('should return true for valid TranslationKeyError objects', () => {
139+
expect(ErrorUtils.isTranslationKeyError({translationKey: 'passwordForm.error.fallback'})).toBe(true);
140+
expect(ErrorUtils.isTranslationKeyError({translationKey: 'passwordForm.error.incorrectLoginOrPassword'})).toBe(true);
141+
expect(ErrorUtils.isTranslationKeyError({translationKey: 'session.offlineMessageRetry'})).toBe(true);
142+
});
143+
144+
test('should return false for null and undefined', () => {
145+
expect(ErrorUtils.isTranslationKeyError(null)).toBe(false);
146+
expect(ErrorUtils.isTranslationKeyError(undefined)).toBe(false);
147+
});
148+
149+
test('should return false for primitive types', () => {
150+
expect(ErrorUtils.isTranslationKeyError(123)).toBe(false);
151+
expect(ErrorUtils.isTranslationKeyError(true)).toBe(false);
152+
expect(ErrorUtils.isTranslationKeyError(false)).toBe(false);
153+
});
154+
});
155+
156+
describe('Integration: getMicroSecondTranslationErrorWithTranslationKey and isTranslationKeyError', () => {
157+
beforeEach(() => {
158+
jest.clearAllMocks();
159+
});
160+
161+
test('should create translation error objects that are correctly identified by isTranslationKeyError', () => {
162+
const mockMicroseconds = 1234567890123;
163+
(DateUtils.getMicroseconds as jest.Mock).mockReturnValue(mockMicroseconds);
164+
165+
const errorObject = ErrorUtils.getMicroSecondTranslationErrorWithTranslationKey('passwordForm.error.incorrectLoginOrPassword');
166+
167+
// The error object should have one key (the timestamp)
168+
const keys = Object.keys(errorObject);
169+
expect(keys).toHaveLength(1);
170+
171+
// The value at that key should be a valid translation key error
172+
const errorValue = errorObject[keys[0]];
173+
expect(ErrorUtils.isTranslationKeyError(errorValue)).toBe(true);
174+
});
175+
176+
test('should work with custom error keys', () => {
177+
const customErrorKey = 9876543210;
178+
const errorObject = ErrorUtils.getMicroSecondTranslationErrorWithTranslationKey('passwordForm.error.fallback', customErrorKey);
179+
180+
// The error object should have the custom key
181+
expect(errorObject).toHaveProperty(customErrorKey.toString());
182+
183+
// The value should be a valid translation key error
184+
const errorValue = errorObject[customErrorKey];
185+
expect(ErrorUtils.isTranslationKeyError(errorValue)).toBe(true);
186+
});
187+
188+
test('should create objects with multiple errors that are all valid translation key errors', () => {
189+
const mockMicroseconds1 = 1111111111111;
190+
const mockMicroseconds2 = 2222222222222;
191+
192+
(DateUtils.getMicroseconds as jest.Mock).mockReturnValueOnce(mockMicroseconds1).mockReturnValueOnce(mockMicroseconds2);
193+
194+
const error1 = ErrorUtils.getMicroSecondTranslationErrorWithTranslationKey('passwordForm.error.incorrectLoginOrPassword');
195+
const error2 = ErrorUtils.getMicroSecondTranslationErrorWithTranslationKey('session.offlineMessageRetry');
196+
197+
// Combine the error objects
198+
const combinedErrors = {...error1, ...error2};
199+
200+
// All values should be valid translation key errors
201+
Object.values(combinedErrors).forEach((errorValue) => {
202+
expect(ErrorUtils.isTranslationKeyError(errorValue)).toBe(true);
203+
});
204+
});
205+
206+
test('should verify the structure of created translation key errors', () => {
207+
const mockMicroseconds = 5555555555555;
208+
(DateUtils.getMicroseconds as jest.Mock).mockReturnValue(mockMicroseconds);
209+
210+
const errorObject = ErrorUtils.getMicroSecondTranslationErrorWithTranslationKey('passwordForm.error.twoFactorAuthenticationEnabled');
211+
const errorValue = errorObject[mockMicroseconds];
212+
213+
// Verify the structure matches what isTranslationKeyError expects
214+
expect(errorValue).toEqual({
215+
translationKey: 'passwordForm.error.twoFactorAuthenticationEnabled',
216+
});
217+
218+
// Verify it passes all the checks in isTranslationKeyError
219+
expect(typeof errorValue).not.toBe('string');
220+
expect(Array.isArray(errorValue)).toBe(false);
221+
expect(Object.keys(errorValue)).toHaveLength(1);
222+
expect(errorValue.translationKey).toBeDefined();
223+
expect(ErrorUtils.isTranslationKeyError(errorValue)).toBe(true);
224+
});
225+
});
65226
});

0 commit comments

Comments
 (0)