diff --git a/src/CONST.ts b/src/CONST.ts
index 25ba86ee3e1a..075f661b8822 100755
--- a/src/CONST.ts
+++ b/src/CONST.ts
@@ -5798,6 +5798,27 @@ const CONST = {
REPORT_ACTIONS: 'actions',
REPORT_ACTION_PREVIEW: 'preview',
},
+
+ REPORT_IN_LHN_REASONS: {
+ HAS_DRAFT_COMMENT: 'hasDraftComment',
+ HAS_GBR: 'hasGBR',
+ PINNED_BY_USER: 'pinnedByUser',
+ HAS_IOU_VIOLATIONS: 'hasIOUViolations',
+ HAS_ADD_WORKSPACE_ROOM_ERRORS: 'hasAddWorkspaceRoomErrors',
+ IS_UNREAD: 'isUnread',
+ IS_ARCHIVED: 'isArchived',
+ IS_SELF_DM: 'isSelfDM',
+ IS_FOCUSED: 'isFocused',
+ DEFAULT: 'default',
+ },
+
+ REQUIRES_ATTENTION_REASONS: {
+ HAS_JOIN_REQUEST: 'hasJoinRequest',
+ IS_UNREAD_WITH_MENTION: 'isUnreadWithMention',
+ IS_WAITING_FOR_ASSIGNEE_TO_COMPLETE_ACTION: 'isWaitingForAssigneeToCompleteAction',
+ HAS_CHILD_REPORT_AWAITING_ACTION: 'hasChildReportAwaitingAction',
+ HAS_MISSING_INVOICE_BANK_ACCOUNT: 'hasMissingInvoiceBankAccount',
+ },
} as const;
type Country = keyof typeof CONST.ALL_COUNTRIES;
diff --git a/src/components/TimePicker/TimePicker.tsx b/src/components/TimePicker/TimePicker.tsx
index f65546295ceb..7f2e34b16b1a 100644
--- a/src/components/TimePicker/TimePicker.tsx
+++ b/src/components/TimePicker/TimePicker.tsx
@@ -37,7 +37,7 @@ type TimePickerProps = {
/** Whether the time value should be validated */
shouldValidate?: boolean;
- /** Whether the picker shows hours, minutes, seconds and miliseconds */
+ /** Whether the picker shows hours, minutes, seconds and milliseconds */
showFullFormat?: boolean;
};
@@ -88,7 +88,7 @@ function replaceRangeWithZeros(originalString: string, from: number, to: number,
}
/**
- * Clear the value under selection of an input (either hours, minutes, seconds or miliseconds) by replacing it with zeros
+ * Clear the value under selection of an input (either hours, minutes, seconds or milliseconds) by replacing it with zeros
*
* @param value - current value of the input
* @param selection - current selection of the input
@@ -135,7 +135,7 @@ function TimePicker({defaultValue = '', onSubmit, onInputChange = () => {}, shou
const [hours, setHours] = useState(() => DateUtils.get12HourTimeObjectFromDate(value, showFullFormat).hour);
const [minutes, setMinutes] = useState(() => DateUtils.get12HourTimeObjectFromDate(value, showFullFormat).minute);
const [seconds, setSeconds] = useState(() => DateUtils.get12HourTimeObjectFromDate(value, showFullFormat).seconds);
- const [miliseconds, setMiliseconds] = useState(() => DateUtils.get12HourTimeObjectFromDate(value, showFullFormat).miliseconds);
+ const [milliseconds, setMilliseconds] = useState(() => DateUtils.get12HourTimeObjectFromDate(value, showFullFormat).milliseconds);
const [amPmValue, setAmPmValue] = useState(() => DateUtils.get12HourTimeObjectFromDate(value, showFullFormat).period);
const lastPressedKey = useRef('');
@@ -189,7 +189,7 @@ function TimePicker({defaultValue = '', onSubmit, onInputChange = () => {}, shou
setSelectionSecond({start: 0, end: 0});
};
- const resetMiliseconds = () => {
+ const resetMilliseconds = () => {
setMinutes('000');
setSelectionMilisecond({start: 0, end: 0});
};
@@ -440,14 +440,14 @@ function TimePicker({defaultValue = '', onSubmit, onInputChange = () => {}, shou
};
/*
- This function receives value from the miliseconds input and validates it.
+ This function receives value from the milliseconds input and validates it.
The valid format is SSS(from 000 to 999). If the user enters 9, it will be prepended to 009. If the user tries to change 999 to 9999, it would skip the character
*/
- const handleMilisecondsChange = (text: string) => {
+ const handleMillisecondsChange = (text: string) => {
// Replace spaces with 0 to implement the following digit removal by pressing space
const trimmedText = text.replace(/ /g, '0');
if (!trimmedText) {
- resetMiliseconds();
+ resetMilliseconds();
return;
}
@@ -460,7 +460,7 @@ function TimePicker({defaultValue = '', onSubmit, onInputChange = () => {}, shou
let newSelection;
if (selectionMilisecond.start === 0 && selectionMilisecond.end === 0) {
- // The cursor is at the start of miliseconds
+ // The cursor is at the start of milliseconds
const firstDigit = trimmedText[0];
const secondDigit = trimmedText[2] || '0';
const thirdDigit = trimmedText[3] || '0';
@@ -514,10 +514,10 @@ function TimePicker({defaultValue = '', onSubmit, onInputChange = () => {}, shou
}
if (Number(newMilisecond) > 999) {
- newMilisecond = miliseconds;
+ newMilisecond = milliseconds;
}
- setMiliseconds(newMilisecond);
+ setMilliseconds(newMilisecond);
setSelectionMilisecond({start: newSelection, end: newSelection});
};
@@ -563,7 +563,7 @@ function TimePicker({defaultValue = '', onSubmit, onInputChange = () => {}, shou
return;
}
- clearSelectedValue(miliseconds, selectionMilisecond, setMiliseconds, setSelectionMilisecond, 3);
+ clearSelectedValue(milliseconds, selectionMilisecond, setMilliseconds, setSelectionMilisecond, 3);
}
return;
}
@@ -576,11 +576,11 @@ function TimePicker({defaultValue = '', onSubmit, onInputChange = () => {}, shou
} else if (isSecondFocused) {
handleSecondsChange(insertAtPosition(seconds, trimmedKey, selectionSecond.start, selectionSecond.end));
} else if (isMilisecondFocused) {
- handleMilisecondsChange(insertAtPosition(miliseconds, trimmedKey, selectionMilisecond.start, selectionMilisecond.end));
+ handleMillisecondsChange(insertAtPosition(milliseconds, trimmedKey, selectionMilisecond.start, selectionMilisecond.end));
}
},
// eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps
- [minutes, hours, seconds, miliseconds, selectionMinute, selectionHour, selectionSecond, selectionMilisecond],
+ [minutes, hours, seconds, milliseconds, selectionMinute, selectionHour, selectionSecond, selectionMilisecond],
);
useEffect(() => {
@@ -690,12 +690,12 @@ function TimePicker({defaultValue = '', onSubmit, onInputChange = () => {}, shou
}, [canUseTouchScreen, updateAmountNumberPad]);
useEffect(() => {
- onInputChange(showFullFormat ? `${hours}:${minutes}:${seconds}.${miliseconds} ${amPmValue}` : `${hours}:${minutes} ${amPmValue}`);
+ onInputChange(showFullFormat ? `${hours}:${minutes}:${seconds}.${milliseconds} ${amPmValue}` : `${hours}:${minutes} ${amPmValue}`);
// eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps
}, [hours, minutes, amPmValue]);
const handleSubmit = () => {
- const time = showFullFormat ? `${hours}:${minutes}:${seconds}.${miliseconds}` : `${hours}:${minutes} ${amPmValue}`;
+ const time = showFullFormat ? `${hours}:${minutes}:${seconds}.${milliseconds}` : `${hours}:${minutes} ${amPmValue}`;
const isValid = validate(time);
if (isValid) {
@@ -796,12 +796,12 @@ function TimePicker({defaultValue = '', onSubmit, onInputChange = () => {}, shou
{CONST.COLON}
{
lastPressedKey.current = e.nativeEvent.key;
handleFocusOnBackspace(e);
}}
- onChangeAmount={handleMilisecondsChange}
+ onChangeAmount={handleMillisecondsChange}
ref={(textInputRef) => {
updateRefs('milisecondRef', textInputRef);
milisecondInputRef.current = textInputRef as TextInput | null;
diff --git a/src/languages/en.ts b/src/languages/en.ts
index e359870cbad6..5415399807e3 100755
--- a/src/languages/en.ts
+++ b/src/languages/en.ts
@@ -4915,6 +4915,29 @@ const translations = {
date: 'Date',
time: 'Time',
none: 'None',
+ visibleInLHN: 'Visible in LHN',
+ GBR: 'GBR',
+ RBR: 'RBR',
+ true: 'true',
+ false: 'false',
+ reasonVisibleInLHN: {
+ hasDraftComment: 'Has draft comment',
+ hasGBR: 'Has GBR',
+ pinnedByUser: 'Pinned by user',
+ hasIOUViolations: 'Has IOU violations',
+ hasAddWorkspaceRoomErrors: 'Has add workspace room errors',
+ isUnread: 'Is unread (focus mode)',
+ isArchived: 'Is archived (most recent mode)',
+ isSelfDM: 'Is self DM',
+ isFocused: 'Is temporarily focused',
+ },
+ reasonGBR: {
+ hasJoinRequest: 'Has join request (admin room)',
+ isUnreadWithMention: 'Is unread with mention',
+ isWaitingForAssigneeToCompleteAction: 'Is waiting for assignee to complete action',
+ hasChildReportAwaitingAction: 'Has child report awaiting action',
+ hasMissingInvoiceBankAccount: 'Has missing invoice bank account',
+ },
},
};
diff --git a/src/languages/es.ts b/src/languages/es.ts
index 7a2ce98b6bdd..fa477e435026 100644
--- a/src/languages/es.ts
+++ b/src/languages/es.ts
@@ -5427,6 +5427,29 @@ const translations = {
date: 'Fecha',
time: 'Hora',
none: 'Ninguno',
+ visibleInLHN: 'Visible en LHN',
+ GBR: 'GBR',
+ RBR: 'RBR',
+ true: 'verdadero',
+ false: 'falso',
+ reasonVisibleInLHN: {
+ hasDraftComment: 'Tiene comentario en borrador',
+ hasGBR: 'Tiene GBR',
+ pinnedByUser: 'Fijado por el usuario',
+ hasIOUViolations: 'Tiene violaciones de IOU',
+ hasAddWorkspaceRoomErrors: 'Tiene errores al agregar sala de espacio de trabajo',
+ isUnread: 'No leído (modo de enfoque)',
+ isArchived: 'Archivado (modo más reciente)',
+ isSelfDM: 'Es un mensaje directo propio',
+ isFocused: 'Está temporalmente enfocado',
+ },
+ reasonGBR: {
+ hasJoinRequest: 'Tiene solicitud de unión (sala de administrador)',
+ isUnreadWithMention: 'No leído con mención',
+ isWaitingForAssigneeToCompleteAction: 'Esperando a que el asignado complete la acción',
+ hasChildReportAwaitingAction: 'Informe secundario pendiente de acción',
+ hasMissingInvoiceBankAccount: 'Falta la cuenta bancaria de la factura',
+ },
},
};
diff --git a/src/libs/DateUtils.ts b/src/libs/DateUtils.ts
index 2de905ff6047..8f1e5c439f24 100644
--- a/src/libs/DateUtils.ts
+++ b/src/libs/DateUtils.ts
@@ -616,15 +616,15 @@ const combineDateAndTime = (updatedTime: string, inputDateTime: string): string
/**
* param {String} dateTime in 'HH:mm:ss.SSS a' format
* returns {Object}
- * example {hour: '11', minute: '10', seconds: '10', miliseconds: '123', period: 'AM'}
+ * example {hour: '11', minute: '10', seconds: '10', milliseconds: '123', period: 'AM'}
*/
-function get12HourTimeObjectFromDate(dateTime: string, isFullFormat = false): {hour: string; minute: string; seconds: string; miliseconds: string; period: string} {
+function get12HourTimeObjectFromDate(dateTime: string, isFullFormat = false): {hour: string; minute: string; seconds: string; milliseconds: string; period: string} {
if (!dateTime) {
return {
hour: '12',
minute: '00',
seconds: '00',
- miliseconds: '000',
+ milliseconds: '000',
period: 'PM',
};
}
@@ -633,7 +633,7 @@ function get12HourTimeObjectFromDate(dateTime: string, isFullFormat = false): {h
hour: format(parsedTime, 'hh'),
minute: format(parsedTime, 'mm'),
seconds: isFullFormat ? format(parsedTime, 'ss') : '00',
- miliseconds: isFullFormat ? format(parsedTime, 'SSS') : '000',
+ milliseconds: isFullFormat ? format(parsedTime, 'SSS') : '000',
period: format(parsedTime, 'a').toUpperCase(),
};
}
diff --git a/src/libs/DebugUtils.ts b/src/libs/DebugUtils.ts
index 3efba2dcd161..ed8e486517ff 100644
--- a/src/libs/DebugUtils.ts
+++ b/src/libs/DebugUtils.ts
@@ -1,9 +1,14 @@
/* eslint-disable max-classes-per-file */
import {isMatch} from 'date-fns';
import isValid from 'date-fns/isValid';
-import type {OnyxEntry} from 'react-native-onyx';
+import type {OnyxCollection, OnyxEntry} from 'react-native-onyx';
+import Onyx from 'react-native-onyx';
import CONST from '@src/CONST';
-import type {Report, ReportAction} from '@src/types/onyx';
+import type {TranslationPaths} from '@src/languages/types';
+import ONYXKEYS from '@src/ONYXKEYS';
+import type {Beta, Policy, Report, ReportAction, ReportActions, TransactionViolation} from '@src/types/onyx';
+import * as OptionsListUtils from './OptionsListUtils';
+import * as ReportUtils from './ReportUtils';
class NumberError extends SyntaxError {
constructor() {
@@ -107,6 +112,40 @@ const REPORT_ACTION_BOOLEAN_PROPERTIES: Array = [
const REPORT_ACTION_DATE_PROPERTIES: Array = ['created', 'lastModified'] satisfies Array;
+let isInFocusMode: OnyxEntry;
+Onyx.connect({
+ key: ONYXKEYS.NVP_PRIORITY_MODE,
+ callback: (priorityMode) => {
+ isInFocusMode = priorityMode === CONST.PRIORITY_MODE.GSD;
+ },
+});
+
+let policies: OnyxCollection;
+Onyx.connect({
+ key: ONYXKEYS.COLLECTION.POLICY,
+ waitForCollectionCallback: true,
+ callback: (value) => {
+ policies = value;
+ },
+});
+
+let transactionViolations: OnyxCollection;
+Onyx.connect({
+ key: ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS,
+ waitForCollectionCallback: true,
+ callback: (value) => {
+ transactionViolations = value;
+ },
+});
+
+let betas: OnyxEntry;
+Onyx.connect({
+ key: ONYXKEYS.BETAS,
+ callback: (value) => {
+ betas = value;
+ },
+});
+
function stringifyJSON(data: Record) {
return JSON.stringify(data, null, 6);
}
@@ -551,6 +590,67 @@ function validateReportActionJSON(json: string) {
});
}
+/**
+ * Gets the reason for showing LHN row
+ */
+function getReasonForShowingRowInLHN(report: OnyxEntry): TranslationPaths | null {
+ if (!report) {
+ return null;
+ }
+
+ const doesReportHaveViolations = OptionsListUtils.shouldShowViolations(report, transactionViolations);
+
+ const reason = ReportUtils.reasonForReportToBeInOptionList({
+ report,
+ // We can't pass report.reportID because it will cause reason to always be isFocused
+ currentReportId: '-1',
+ isInFocusMode: !!isInFocusMode,
+ betas,
+ policies,
+ excludeEmptyChats: true,
+ doesReportHaveViolations,
+ includeSelfDM: true,
+ });
+
+ // When there's no specific reason, we default to isFocused since the report is only showing because we're viewing it
+ if (reason === null || reason === CONST.REPORT_IN_LHN_REASONS.DEFAULT) {
+ return 'debug.reasonVisibleInLHN.isFocused';
+ }
+
+ return `debug.reasonVisibleInLHN.${reason}`;
+}
+
+type GBRReasonAndReportAction = {
+ reason: TranslationPaths;
+ reportAction: OnyxEntry;
+};
+
+/**
+ * Gets the reason and report action that is causing the GBR to show up in LHN row
+ */
+function getReasonAndReportActionForGBRInLHNRow(report: OnyxEntry): GBRReasonAndReportAction | null {
+ if (!report) {
+ return null;
+ }
+
+ const {reason, reportAction} = ReportUtils.getReasonAndReportActionThatRequiresAttention(report) ?? {};
+
+ if (reason) {
+ return {reason: `debug.reasonGBR.${reason}`, reportAction};
+ }
+
+ return null;
+}
+
+/**
+ * Gets the report action that is causing the RBR to show up in LHN
+ */
+function getRBRReportAction(report: OnyxEntry, reportActions: OnyxEntry): OnyxEntry {
+ const {reportAction} = OptionsListUtils.getAllReportActionsErrorsAndReportActionThatRequiresAttention(report, reportActions);
+
+ return reportAction;
+}
+
const DebugUtils = {
stringifyJSON,
onyxDataToDraftData,
@@ -568,6 +668,9 @@ const DebugUtils = {
validateReportDraftProperty,
validateReportActionDraftProperty,
validateReportActionJSON,
+ getReasonForShowingRowInLHN,
+ getReasonAndReportActionForGBRInLHNRow,
+ getRBRReportAction,
REPORT_ACTION_REQUIRED_PROPERTIES,
REPORT_REQUIRED_PROPERTIES,
};
diff --git a/src/libs/OptionsListUtils.ts b/src/libs/OptionsListUtils.ts
index 90320b4a9ea1..f7a7d374112d 100644
--- a/src/libs/OptionsListUtils.ts
+++ b/src/libs/OptionsListUtils.ts
@@ -480,18 +480,23 @@ function uniqFast(items: string[]): string[] {
return result;
}
-/**
- * Get an object of error messages keyed by microtime by combining all error objects related to the report.
- */
-function getAllReportErrors(report: OnyxEntry, reportActions: OnyxEntry): OnyxCommon.Errors {
- const reportErrors = report?.errors ?? {};
- const reportErrorFields = report?.errorFields ?? {};
+type ReportErrorsAndReportActionThatRequiresAttention = {
+ errors: OnyxCommon.ErrorFields;
+ reportAction?: OnyxEntry;
+};
+
+function getAllReportActionsErrorsAndReportActionThatRequiresAttention(report: OnyxEntry, reportActions: OnyxEntry): ReportErrorsAndReportActionThatRequiresAttention {
const reportActionsArray = Object.values(reportActions ?? {});
const reportActionErrors: OnyxCommon.ErrorFields = {};
+ let reportAction: OnyxEntry;
for (const action of reportActionsArray) {
if (action && !isEmptyObject(action.errors)) {
Object.assign(reportActionErrors, action.errors);
+
+ if (!reportAction) {
+ reportAction = action;
+ }
}
}
const parentReportAction: OnyxEntry =
@@ -502,14 +507,32 @@ function getAllReportErrors(report: OnyxEntry, reportActions: OnyxEntry<
const transaction = allTransactions?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`];
if (TransactionUtils.hasMissingSmartscanFields(transaction ?? null) && !ReportUtils.isSettled(transaction?.reportID)) {
reportActionErrors.smartscan = ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('iou.error.genericSmartscanFailureMessage');
+ reportAction = undefined;
}
} else if ((ReportUtils.isIOUReport(report) || ReportUtils.isExpenseReport(report)) && report?.ownerAccountID === currentUserAccountID) {
if (ReportUtils.shouldShowRBRForMissingSmartscanFields(report?.reportID ?? '-1') && !ReportUtils.isSettled(report?.reportID)) {
reportActionErrors.smartscan = ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('iou.error.genericSmartscanFailureMessage');
+ reportAction = ReportUtils.getReportActionWithMissingSmartscanFields(report?.reportID ?? '-1');
}
} else if (ReportUtils.hasSmartscanError(reportActionsArray)) {
reportActionErrors.smartscan = ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('iou.error.genericSmartscanFailureMessage');
+ reportAction = ReportUtils.getReportActionWithSmartscanError(reportActionsArray);
}
+
+ return {
+ errors: reportActionErrors,
+ reportAction,
+ };
+}
+
+/**
+ * Get an object of error messages keyed by microtime by combining all error objects related to the report.
+ */
+function getAllReportErrors(report: OnyxEntry, reportActions: OnyxEntry): OnyxCommon.Errors {
+ const reportErrors = report?.errors ?? {};
+ const reportErrorFields = report?.errorFields ?? {};
+ const {errors: reportActionErrors} = getAllReportActionsErrorsAndReportActionThatRequiresAttention(report, reportActions);
+
// All error objects related to the report. Each object in the sources contains error messages keyed by microtime
const errorSources = {
reportErrors,
@@ -711,6 +734,10 @@ function getLastMessageTextForReport(report: OnyxEntry, lastActorDetails
return lastMessageTextFromReport || (report?.lastMessageText ?? '');
}
+function hasReportErrors(report: Report, reportActions: OnyxEntry) {
+ return !isEmptyObject(getAllReportErrors(report, reportActions));
+}
+
/**
* Creates a report list option
*/
@@ -777,7 +804,7 @@ function createOption(
result.isPolicyExpenseChat = ReportUtils.isPolicyExpenseChat(report);
result.isOwnPolicyExpenseChat = report.isOwnPolicyExpenseChat ?? false;
result.allReportErrors = getAllReportErrors(report, reportActions);
- result.brickRoadIndicator = !isEmptyObject(result.allReportErrors) ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : '';
+ result.brickRoadIndicator = hasReportErrors(report, reportActions) ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : '';
result.pendingAction = report.pendingFields ? report.pendingFields.addWorkspaceRoom ?? report.pendingFields.createChat : undefined;
result.ownerAccountID = report.ownerAccountID;
result.reportID = report.reportID;
@@ -2620,6 +2647,8 @@ export {
getEmptyOptions,
shouldUseBoldText,
getAlternateText,
+ getAllReportActionsErrorsAndReportActionThatRequiresAttention,
+ hasReportErrors,
};
export type {MemberForList, CategorySection, CategoryTreeSection, Options, OptionList, SearchOption, PayeePersonalDetails, Category, Tax, TaxRatesOption, Option, OptionTree};
diff --git a/src/libs/ReportActionsUtils.ts b/src/libs/ReportActionsUtils.ts
index 486943494854..353a13a1e34c 100644
--- a/src/libs/ReportActionsUtils.ts
+++ b/src/libs/ReportActionsUtils.ts
@@ -1471,15 +1471,20 @@ function isActionableJoinRequest(reportAction: OnyxEntry): reportA
return isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.ACTIONABLE_JOIN_REQUEST);
}
+function getActionableJoinRequestPendingReportAction(reportID: string): OnyxEntry {
+ const findPendingRequest = Object.values(getAllReportActions(reportID)).find(
+ (reportActionItem) => isActionableJoinRequest(reportActionItem) && getOriginalMessage(reportActionItem)?.choice === ('' as JoinWorkspaceResolution),
+ );
+
+ return findPendingRequest;
+}
+
/**
* Checks if any report actions correspond to a join request action that is still pending.
* @param reportID
*/
function isActionableJoinRequestPending(reportID: string): boolean {
- const findPendingRequest = Object.values(getAllReportActions(reportID)).find(
- (reportActionItem) => isActionableJoinRequest(reportActionItem) && getOriginalMessage(reportActionItem)?.choice === ('' as JoinWorkspaceResolution),
- );
- return !!findPendingRequest;
+ return !!getActionableJoinRequestPendingReportAction(reportID);
}
function isApprovedOrSubmittedReportAction(action: OnyxEntry) {
@@ -1869,6 +1874,7 @@ export {
isCardIssuedAction,
getCardIssuedMessage,
getRemovedConnectionMessage,
+ getActionableJoinRequestPendingReportAction,
};
export type {LastVisibleMessage};
diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts
index 3d016fab713d..4485c597b0a4 100644
--- a/src/libs/ReportUtils.ts
+++ b/src/libs/ReportUtils.ts
@@ -2634,58 +2634,90 @@ function isUnreadWithMention(reportOrOption: OnyxEntry | OptionData): bo
return !!('isUnreadWithMention' in reportOrOption && reportOrOption.isUnreadWithMention) || lastReadTime < lastMentionedTime;
}
-/**
- * Determines if the option requires action from the current user. This can happen when it:
- * - is unread and the user was mentioned in one of the unread comments
- * - is for an outstanding task waiting on the user
- * - has an outstanding child expense that is waiting for an action from the current user (e.g. pay, approve, add bank account)
- * - is either the system or concierge chat, the user free trial has ended and it didn't add a payment card yet
- *
- * @param option (report or optionItem)
- * @param parentReportAction (the report action the current report is a thread of)
- */
-function requiresAttentionFromCurrentUser(optionOrReport: OnyxEntry | OptionData, parentReportAction?: OnyxEntry) {
+type ReasonAndReportActionThatRequiresAttention = {
+ reason: ValueOf;
+ reportAction?: OnyxEntry;
+};
+
+function getReasonAndReportActionThatRequiresAttention(
+ optionOrReport: OnyxEntry | OptionData,
+ parentReportAction?: OnyxEntry,
+): ReasonAndReportActionThatRequiresAttention | null {
if (!optionOrReport) {
- return false;
+ return null;
}
+ const reportActions = ReportActionsUtils.getAllReportActions(optionOrReport.reportID);
+
if (isJoinRequestInAdminRoom(optionOrReport)) {
- return true;
+ return {
+ reason: CONST.REQUIRES_ATTENTION_REASONS.HAS_JOIN_REQUEST,
+ reportAction: ReportActionsUtils.getActionableJoinRequestPendingReportAction(optionOrReport.reportID),
+ };
}
if (
isArchivedRoom(optionOrReport, getReportNameValuePairs(optionOrReport?.reportID)) ||
isArchivedRoom(getReportOrDraftReport(optionOrReport.parentReportID), getReportNameValuePairs(optionOrReport?.reportID))
) {
- return false;
+ return null;
}
if (isUnreadWithMention(optionOrReport)) {
- return true;
+ return {
+ reason: CONST.REQUIRES_ATTENTION_REASONS.IS_UNREAD_WITH_MENTION,
+ };
}
if (isWaitingForAssigneeToCompleteAction(optionOrReport, parentReportAction)) {
- return true;
+ return {
+ reason: CONST.REQUIRES_ATTENTION_REASONS.IS_WAITING_FOR_ASSIGNEE_TO_COMPLETE_ACTION,
+ reportAction: Object.values(reportActions).find((action) => action.childType === CONST.REPORT.TYPE.TASK),
+ };
}
// Has a child report that is awaiting action (e.g. approve, pay, add bank account) from current user
if (optionOrReport.hasOutstandingChildRequest) {
- return true;
+ return {
+ reason: CONST.REQUIRES_ATTENTION_REASONS.HAS_CHILD_REPORT_AWAITING_ACTION,
+ reportAction: IOU.getIOUReportActionToApproveOrPay(optionOrReport, optionOrReport.reportID),
+ };
}
if (hasMissingInvoiceBankAccount(optionOrReport.reportID)) {
- return true;
+ return {
+ reason: CONST.REQUIRES_ATTENTION_REASONS.HAS_MISSING_INVOICE_BANK_ACCOUNT,
+ };
}
if (isInvoiceRoom(optionOrReport)) {
- const invoiceRoomReportActions = ReportActionsUtils.getAllReportActions(optionOrReport.reportID);
-
- return Object.values(invoiceRoomReportActions).some(
- (reportAction) => reportAction.actionName === CONST.REPORT.ACTIONS.TYPE.REPORT_PREVIEW && reportAction.childReportID && hasMissingInvoiceBankAccount(reportAction.childReportID),
+ const reportAction = Object.values(reportActions).find(
+ (action) => action.actionName === CONST.REPORT.ACTIONS.TYPE.REPORT_PREVIEW && action.childReportID && hasMissingInvoiceBankAccount(action.childReportID),
);
+
+ return reportAction
+ ? {
+ reason: CONST.REQUIRES_ATTENTION_REASONS.HAS_MISSING_INVOICE_BANK_ACCOUNT,
+ reportAction,
+ }
+ : null;
}
- return false;
+ return null;
+}
+
+/**
+ * Determines if the option requires action from the current user. This can happen when it:
+ * - is unread and the user was mentioned in one of the unread comments
+ * - is for an outstanding task waiting on the user
+ * - has an outstanding child expense that is waiting for an action from the current user (e.g. pay, approve, add bank account)
+ * - is either the system or concierge chat, the user free trial has ended and it didn't add a payment card yet
+ *
+ * @param option (report or optionItem)
+ * @param parentReportAction (the report action the current report is a thread of)
+ */
+function requiresAttentionFromCurrentUser(optionOrReport: OnyxEntry | OptionData, parentReportAction?: OnyxEntry) {
+ return !!getReasonAndReportActionThatRequiresAttention(optionOrReport, parentReportAction);
}
/**
@@ -3262,11 +3294,11 @@ function hasMissingSmartscanFields(iouReportID: string): boolean {
}
/**
- * Check if iouReportID has required missing fields
+ * Get report action which is missing smartscan fields
*/
-function shouldShowRBRForMissingSmartscanFields(iouReportID: string): boolean {
+function getReportActionWithMissingSmartscanFields(iouReportID: string): ReportAction | undefined {
const reportActions = Object.values(ReportActionsUtils.getAllReportActions(iouReportID));
- return reportActions.some((action) => {
+ return reportActions.find((action) => {
if (!ReportActionsUtils.isMoneyRequestAction(action)) {
return false;
}
@@ -3281,6 +3313,13 @@ function shouldShowRBRForMissingSmartscanFields(iouReportID: string): boolean {
});
}
+/**
+ * Check if iouReportID has required missing fields
+ */
+function shouldShowRBRForMissingSmartscanFields(iouReportID: string): boolean {
+ return !!getReportActionWithMissingSmartscanFields(iouReportID);
+}
+
/**
* Given a parent IOU report action get report name for the LHN.
*/
@@ -6143,25 +6182,7 @@ function shouldAdminsRoomBeVisible(report: OnyxEntry): boolean {
return true;
}
-/**
- * Takes several pieces of data from Onyx and evaluates if a report should be shown in the option list (either when searching
- * for reports or the reports shown in the LHN).
- *
- * This logic is very specific and the order of the logic is very important. It should fail quickly in most cases and also
- * filter out the majority of reports before filtering out very specific minority of reports.
- */
-function shouldReportBeInOptionList({
- report,
- currentReportId,
- isInFocusMode,
- betas,
- policies,
- excludeEmptyChats,
- doesReportHaveViolations,
- includeSelfDM = false,
- login,
- includeDomainEmail = false,
-}: {
+type ShouldReportBeInOptionListParams = {
report: OnyxEntry;
currentReportId: string;
isInFocusMode: boolean;
@@ -6172,7 +6193,20 @@ function shouldReportBeInOptionList({
includeSelfDM?: boolean;
login?: string;
includeDomainEmail?: boolean;
-}) {
+};
+
+function reasonForReportToBeInOptionList({
+ report,
+ currentReportId,
+ isInFocusMode,
+ betas,
+ policies,
+ excludeEmptyChats,
+ doesReportHaveViolations,
+ includeSelfDM = false,
+ login,
+ includeDomainEmail = false,
+}: ShouldReportBeInOptionListParams): ValueOf | null {
const isInDefaultMode = !isInFocusMode;
// Exclude reports that have no data because there wouldn't be anything to show in the option item.
// This can happen if data is currently loading from the server or a report is in various stages of being created.
@@ -6199,34 +6233,34 @@ function shouldReportBeInOptionList({
!isSystemChat(report) &&
!isGroupChat(report))
) {
- return false;
+ return null;
}
// We used to use the system DM for A/B testing onboarding tasks, but now only create them in the Concierge chat. We
// still need to allow existing users who have tasks in the system DM to see them, but otherwise we don't need to
// show that chat
if (report?.participants?.[CONST.ACCOUNT_ID.NOTIFICATIONS] && isEmptyReport(report)) {
- return false;
+ return null;
}
if (!canAccessReport(report, policies, betas)) {
- return false;
+ return null;
}
// If this is a transaction thread associated with a report that only has one transaction, omit it
if (isOneTransactionThread(report.reportID, report.parentReportID ?? '-1', parentReportAction)) {
- return false;
+ return null;
}
if ((Object.values(CONST.REPORT.UNSUPPORTED_TYPE) as string[]).includes(report?.type ?? '')) {
- return false;
+ return null;
}
// Include the currently viewed report. If we excluded the currently viewed report, then there
// would be no way to highlight it in the options list and it would be confusing to users because they lose
// a sense of context.
if (report.reportID === currentReportId) {
- return true;
+ return CONST.REPORT_IN_LHN_REASONS.IS_FOCUSED;
}
// Retrieve the draft comment for the report and convert it to a boolean
@@ -6234,8 +6268,12 @@ function shouldReportBeInOptionList({
// Include reports that are relevant to the user in any view mode. Criteria include having a draft or having a GBR showing.
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
- if (hasDraftComment || requiresAttentionFromCurrentUser(report)) {
- return true;
+ if (hasDraftComment) {
+ return CONST.REPORT_IN_LHN_REASONS.HAS_DRAFT_COMMENT;
+ }
+
+ if (requiresAttentionFromCurrentUser(report)) {
+ return CONST.REPORT_IN_LHN_REASONS.HAS_GBR;
}
const isEmptyChat = isEmptyReport(report);
@@ -6243,53 +6281,53 @@ function shouldReportBeInOptionList({
// Include reports if they are pinned
if (report.isPinned) {
- return true;
+ return CONST.REPORT_IN_LHN_REASONS.PINNED_BY_USER;
}
const reportIsSettled = report.statusNum === CONST.REPORT.STATUS_NUM.REIMBURSED;
// Always show IOU reports with violations unless they are reimbursed
if (isExpenseRequest(report) && doesReportHaveViolations && !reportIsSettled) {
- return true;
+ return CONST.REPORT_IN_LHN_REASONS.HAS_IOU_VIOLATIONS;
}
// Hide only chat threads that haven't been commented on (other threads are actionable)
if (isChatThread(report) && canHideReport && isEmptyChat) {
- return false;
+ return null;
}
// Show #admins room only when it has some value to the user.
if (isAdminRoom(report) && !shouldAdminsRoomBeVisible(report)) {
- return false;
+ return null;
}
// Include reports that have errors from trying to add a workspace
// If we excluded it, then the red-brock-road pattern wouldn't work for the user to resolve the error
if (report.errorFields?.addWorkspaceRoom) {
- return true;
+ return CONST.REPORT_IN_LHN_REASONS.HAS_ADD_WORKSPACE_ROOM_ERRORS;
}
// All unread chats (even archived ones) in GSD mode will be shown. This is because GSD mode is specifically for focusing the user on the most relevant chats, primarily, the unread ones
if (isInFocusMode) {
- return isUnread(report) && getReportNotificationPreference(report) !== CONST.REPORT.NOTIFICATION_PREFERENCE.MUTE;
+ return isUnread(report) && getReportNotificationPreference(report) !== CONST.REPORT.NOTIFICATION_PREFERENCE.MUTE ? CONST.REPORT_IN_LHN_REASONS.IS_UNREAD : null;
}
// Archived reports should always be shown when in default (most recent) mode. This is because you should still be able to access and search for the chats to find them.
if (isInDefaultMode && isArchivedRoom(report, getReportNameValuePairs(report?.reportID))) {
- return true;
+ return CONST.REPORT_IN_LHN_REASONS.IS_ARCHIVED;
}
// Hide chats between two users that haven't been commented on from the LNH
if (excludeEmptyChats && isEmptyChat && isChatReport(report) && !isChatRoom(report) && !isPolicyExpenseChat(report) && !isSystemChat(report) && !isGroupChat(report) && canHideReport) {
- return false;
+ return null;
}
if (isSelfDM(report)) {
- return includeSelfDM;
+ return includeSelfDM ? CONST.REPORT_IN_LHN_REASONS.IS_SELF_DM : null;
}
if (Str.isDomainEmail(login ?? '') && !includeDomainEmail) {
- return false;
+ return null;
}
// Hide chat threads where the parent message is pending removal
@@ -6298,10 +6336,21 @@ function shouldReportBeInOptionList({
ReportActionsUtils.isPendingRemove(parentReportAction) &&
ReportActionsUtils.isThreadParentMessage(parentReportAction, report?.reportID ?? '')
) {
- return false;
+ return null;
}
- return true;
+ return CONST.REPORT_IN_LHN_REASONS.DEFAULT;
+}
+
+/**
+ * Takes several pieces of data from Onyx and evaluates if a report should be shown in the option list (either when searching
+ * for reports or the reports shown in the LHN).
+ *
+ * This logic is very specific and the order of the logic is very important. It should fail quickly in most cases and also
+ * filter out the majority of reports before filtering out very specific minority of reports.
+ */
+function shouldReportBeInOptionList(params: ShouldReportBeInOptionListParams) {
+ return reasonForReportToBeInOptionList(params) !== null;
}
/**
@@ -7304,11 +7353,8 @@ function canEditPolicyDescription(policy: OnyxEntry): boolean {
return PolicyUtils.isPolicyAdmin(policy);
}
-/**
- * Checks if report action has error when smart scanning
- */
-function hasSmartscanError(reportActions: ReportAction[]) {
- return reportActions.some((action) => {
+function getReportActionWithSmartscanError(reportActions: ReportAction[]): ReportAction | undefined {
+ return reportActions.find((action) => {
const isReportPreview = ReportActionsUtils.isReportPreviewAction(action);
const isSplitReportAction = ReportActionsUtils.isSplitBillAction(action);
if (!isSplitReportAction && !isReportPreview) {
@@ -7328,6 +7374,13 @@ function hasSmartscanError(reportActions: ReportAction[]) {
});
}
+/**
+ * Checks if report action has error when smart scanning
+ */
+function hasSmartscanError(reportActions: ReportAction[]): boolean {
+ return !!getReportActionWithSmartscanError(reportActions);
+}
+
function shouldAutoFocusOnKeyPress(event: KeyboardEvent): boolean {
if (event.key.length > 1) {
return false;
@@ -8188,6 +8241,7 @@ export {
hasOnlyHeldExpenses,
hasOnlyTransactionsWithPendingRoutes,
hasReportNameError,
+ getReportActionWithSmartscanError,
hasSmartscanError,
hasUpdatedTotal,
hasViolations,
@@ -8296,6 +8350,7 @@ export {
shouldReportBeInOptionList,
shouldReportShowSubscript,
shouldShowFlagComment,
+ getReportActionWithMissingSmartscanFields,
shouldShowRBRForMissingSmartscanFields,
shouldUseFullTitleToDisplay,
updateOptimisticParentReportAction,
@@ -8334,6 +8389,8 @@ export {
isIndividualInvoiceRoom,
isAuditor,
hasMissingInvoiceBankAccount,
+ reasonForReportToBeInOptionList,
+ getReasonAndReportActionThatRequiresAttention,
};
export type {
diff --git a/src/libs/SidebarUtils.ts b/src/libs/SidebarUtils.ts
index dd2902c91bfe..99811645a6ea 100644
--- a/src/libs/SidebarUtils.ts
+++ b/src/libs/SidebarUtils.ts
@@ -234,6 +234,27 @@ function getOrderedReportIDs(
return LHNReports;
}
+function shouldShowRedBrickRoad(report: Report, reportActions: OnyxEntry, hasViolations: boolean, transactionViolations?: OnyxCollection) {
+ const hasErrors = Object.keys(OptionsListUtils.getAllReportErrors(report, reportActions)).length !== 0;
+
+ const oneTransactionThreadReportID = ReportActionsUtils.getOneTransactionThreadReportID(report.reportID, ReportActionsUtils.getAllReportActions(report.reportID));
+ if (oneTransactionThreadReportID) {
+ const oneTransactionThreadReport = ReportUtils.getReport(oneTransactionThreadReportID);
+
+ if (
+ ReportUtils.shouldDisplayTransactionThreadViolations(
+ oneTransactionThreadReport,
+ transactionViolations,
+ ReportActionsUtils.getAllReportActions(report.reportID)[oneTransactionThreadReport?.parentReportActionID ?? '-1'],
+ )
+ ) {
+ return true;
+ }
+ }
+
+ return hasErrors || hasViolations;
+}
+
/**
* Gets all the data necessary for rendering an OptionRowLHN component
*/
@@ -306,7 +327,6 @@ function getOptionData({
const participantPersonalDetailList = Object.values(OptionsListUtils.getPersonalDetailsForAccountIDs(participantAccountIDs, personalDetails));
const personalDetail = participantPersonalDetailList.at(0) ?? ({} as PersonalDetails);
- const hasErrors = Object.keys(result.allReportErrors ?? {}).length !== 0;
result.isThread = ReportUtils.isChatThread(report);
result.isChatRoom = ReportUtils.isChatRoom(report);
@@ -320,21 +340,7 @@ function getOptionData({
result.isMoneyRequestReport = ReportUtils.isMoneyRequestReport(report);
result.shouldShowSubscript = ReportUtils.shouldReportShowSubscript(report);
result.pendingAction = report.pendingFields?.addWorkspaceRoom ?? report.pendingFields?.createChat;
- result.brickRoadIndicator = hasErrors || hasViolations ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : '';
- const oneTransactionThreadReportID = ReportActionsUtils.getOneTransactionThreadReportID(report.reportID, ReportActionsUtils.getAllReportActions(report.reportID));
- if (oneTransactionThreadReportID) {
- const oneTransactionThreadReport = ReportUtils.getReport(oneTransactionThreadReportID);
-
- if (
- ReportUtils.shouldDisplayTransactionThreadViolations(
- oneTransactionThreadReport,
- transactionViolations,
- ReportActionsUtils.getAllReportActions(report.reportID)[oneTransactionThreadReport?.parentReportActionID ?? '-1'],
- )
- ) {
- result.brickRoadIndicator = CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR;
- }
- }
+ result.brickRoadIndicator = shouldShowRedBrickRoad(report, reportActions, hasViolations, transactionViolations) ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : '';
result.ownerAccountID = report.ownerAccountID;
result.managerID = report.managerID;
result.reportID = report.reportID;
@@ -624,4 +630,5 @@ export default {
getOptionData,
getOrderedReportIDs,
getWelcomeMessage,
+ shouldShowRedBrickRoad,
};
diff --git a/src/libs/actions/IOU.ts b/src/libs/actions/IOU.ts
index 8d8e25a3ffb6..8b57f3e090a4 100644
--- a/src/libs/actions/IOU.ts
+++ b/src/libs/actions/IOU.ts
@@ -6990,10 +6990,10 @@ function canIOUBePaid(
);
}
-function hasIOUToApproveOrPay(chatReport: OnyxEntry, excludedIOUReportID: string): boolean {
+function getIOUReportActionToApproveOrPay(chatReport: OnyxEntry, excludedIOUReportID: string): OnyxEntry {
const chatReportActions = allReportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${chatReport?.reportID}`] ?? {};
- return Object.values(chatReportActions).some((action) => {
+ return Object.values(chatReportActions).find((action) => {
const iouReport = ReportUtils.getReportOrDraftReport(action.childReportID ?? '-1');
const policy = PolicyUtils.getPolicy(iouReport?.policyID);
const shouldShowSettlementButton = canIOUBePaid(iouReport, chatReport, policy) || canApproveIOU(iouReport, policy);
@@ -7001,6 +7001,10 @@ function hasIOUToApproveOrPay(chatReport: OnyxEntry, excludedI
});
}
+function hasIOUToApproveOrPay(chatReport: OnyxEntry, excludedIOUReportID: string): boolean {
+ return !!getIOUReportActionToApproveOrPay(chatReport, excludedIOUReportID);
+}
+
function isLastApprover(approvalChain: string[]): boolean {
if (approvalChain.length === 0) {
return true;
@@ -8476,5 +8480,6 @@ export {
updateMoneyRequestTaxRate,
mergeDuplicates,
resolveDuplicates,
+ getIOUReportActionToApproveOrPay,
};
export type {GPSPoint as GpsPoint, IOURequestType};
diff --git a/src/pages/Debug/DateTimeSelector.tsx b/src/pages/Debug/DateTimeSelector.tsx
index 83307dbbe37c..0aa85a2a23a4 100644
--- a/src/pages/Debug/DateTimeSelector.tsx
+++ b/src/pages/Debug/DateTimeSelector.tsx
@@ -6,13 +6,13 @@ import CONST from '@src/CONST';
import ROUTES from '@src/ROUTES';
type DateTimeSelectorProps = {
- /** Form error text. e.g when no constant is selected */
+ /** Form error text. e.g when no datetime is selected */
errorText?: string;
- /** Callback called when the constant changes. */
+ /** Callback called when the datetime changes. */
onInputChange?: (value?: string) => void;
- /** Current selected constant */
+ /** Current datetime */
value?: string;
/** Name of the field */
@@ -27,17 +27,17 @@ function DateTimeSelector({errorText = '', name, value, onInputChange}: DateTime
const fieldValue = (useRoute().params as Record | undefined)?.[name];
useEffect(() => {
- // If no constant is selected from the URL, exit the effect early to avoid further processing.
+ // If no datetime is present in the URL, exit the effect early to avoid further processing.
if (!fieldValue) {
return;
}
- // If a constant is selected, invoke `onInputChange` to update the form and clear any validation errors related to the constant selection.
+ // If datetime is present, invoke `onInputChange` to update the form and clear any validation errors related to the constant selection.
if (onInputChange) {
onInputChange(fieldValue);
}
- // Clears the `constant` parameter from the URL to ensure the component constant is driven by the parent component rather than URL parameters.
+ // Clears the `datetime` parameter from the URL to ensure the component datetime is driven by the parent component rather than URL parameters.
// This helps prevent issues where the component might not update correctly if the country is controlled by both the parent and the URL.
Navigation.setParams({[name]: undefined});
}, [fieldValue, name, onInputChange]);
diff --git a/src/pages/Debug/DebugDetails.tsx b/src/pages/Debug/DebugDetails.tsx
index 6ccafda974f4..c64e8e3a9331 100644
--- a/src/pages/Debug/DebugDetails.tsx
+++ b/src/pages/Debug/DebugDetails.tsx
@@ -12,7 +12,7 @@ import Text from '@components/Text';
import TextInput from '@components/TextInput';
import useLocalize from '@hooks/useLocalize';
import useThemeStyles from '@hooks/useThemeStyles';
-import type {OnyxDataType} from '@libs/DebugUtils';
+import type {ObjectType, OnyxDataType} from '@libs/DebugUtils';
import DebugUtils from '@libs/DebugUtils';
import Navigation from '@libs/Navigation/Navigation';
import Debug from '@userActions/Debug';
@@ -28,17 +28,20 @@ type DebugDetailsProps = {
/** The report or report action data to be displayed and editted. */
data: OnyxEntry | OnyxEntry;
+ children?: React.ReactNode;
+
/** Callback to be called when user saves the debug data. */
- onSave: (values: FormOnyxValues) => void;
+ onSave: (values: Record) => void;
/** Callback to be called when user deletes the debug data. */
onDelete: () => void;
/** Callback to be called every time the debug data form is validated. */
- validate: (key: never, value: string) => void;
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ validate: (key: any, value: string) => void;
};
-function DebugDetails({data, onSave, onDelete, validate}: DebugDetailsProps) {
+function DebugDetails({data, children, onSave, onDelete, validate}: DebugDetailsProps) {
const {translate} = useLocalize();
const styles = useThemeStyles();
const [formDraftData] = useOnyx(ONYXKEYS.FORMS.DEBUG_DETAILS_FORM_DRAFT);
@@ -52,45 +55,46 @@ function DebugDetails({data, onSave, onDelete, validate}: DebugDetailsProps) {
const constantFields = useMemo(
() =>
Object.entries(data ?? {})
- .filter(([key]) => DETAILS_CONSTANT_FIELDS.includes(key as DetailsConstantFieldsKeys))
- .sort((a, b) => a[0].localeCompare(b[0])) as Array<[string, string]>,
+ .filter((entry): entry is [string, string] => DETAILS_CONSTANT_FIELDS.includes(entry[0] as DetailsConstantFieldsKeys))
+ .sort((a, b) => a[0].localeCompare(b[0])),
[data],
);
const numberFields = useMemo(
() =>
Object.entries(data ?? {})
- .filter(([, value]) => typeof value === 'number')
- .sort((a, b) => a[0].localeCompare(b[0])) as Array<[string, number]>,
+ .filter((entry): entry is [string, number] => typeof entry[1] === 'number')
+ .sort((a, b) => a[0].localeCompare(b[0])),
[data],
);
const textFields = useMemo(
() =>
Object.entries(data ?? {})
.filter(
- ([key, value]) =>
- (typeof value === 'string' || typeof value === 'object') &&
- !DETAILS_CONSTANT_FIELDS.includes(key as DetailsConstantFieldsKeys) &&
- !DETAILS_DATETIME_FIELDS.includes(key as DetailsDatetimeFieldsKeys),
+ (entry): entry is [string, string | ObjectType] =>
+ (typeof entry[1] === 'string' || typeof entry[1] === 'object') &&
+ !DETAILS_CONSTANT_FIELDS.includes(entry[0] as DetailsConstantFieldsKeys) &&
+ !DETAILS_DATETIME_FIELDS.includes(entry[0] as DetailsDatetimeFieldsKeys),
)
- // eslint-disable-next-line @typescript-eslint/no-unsafe-argument
.map(([key, value]) => [key, DebugUtils.onyxDataToString(value)])
.sort((a, b) => (a.at(0) ?? '').localeCompare(b.at(0) ?? '')),
[data],
);
- const dateTimeFields = useMemo(() => Object.entries(data ?? {}).filter(([key]) => DETAILS_DATETIME_FIELDS.includes(key as DetailsDatetimeFieldsKeys)) as Array<[string, string]>, [data]);
+ const dateTimeFields = useMemo(
+ () => Object.entries(data ?? {}).filter((entry): entry is [string, string] => DETAILS_DATETIME_FIELDS.includes(entry[0] as DetailsDatetimeFieldsKeys)),
+ [data],
+ );
const validator = useCallback(
(values: FormOnyxValues): FormInputErrors => {
const newErrors: Record = {};
Object.entries(values).forEach(([key, value]) => {
try {
- validate(key as never, DebugUtils.onyxDataToString(value));
+ validate(key, DebugUtils.onyxDataToString(value));
} catch (e) {
const {cause, message} = e as SyntaxError;
newErrors[key] = cause || message === 'debug.missingValue' ? translate(message as TranslationPaths, cause as never) : message;
}
});
-
return newErrors;
},
[translate, validate],
@@ -102,11 +106,11 @@ function DebugDetails({data, onSave, onDelete, validate}: DebugDetailsProps) {
const handleSubmit = useCallback(
(values: FormOnyxValues) => {
- const dataPreparedToSave = Object.entries(values).reduce((acc: FormOnyxValues, [key, value]) => {
+ const dataPreparedToSave = Object.entries(values).reduce((acc: Record, [key, value]) => {
if (typeof value === 'boolean') {
acc[key] = value;
} else {
- acc[key] = DebugUtils.stringToOnyxData(value as string, typeof data?.[key as keyof Report & keyof ReportAction] as OnyxDataType);
+ acc[key] = DebugUtils.stringToOnyxData(value, typeof data?.[key as keyof typeof data] as OnyxDataType);
}
return acc;
}, {});
@@ -130,6 +134,7 @@ function DebugDetails({data, onSave, onDelete, validate}: DebugDetailsProps) {
return (
+ {children}
{translate('debug.textFields')}
{textFields.map(([key, value]) => {
- const numberOfLines = DebugUtils.getNumberOfLinesFromString((formDraftData?.[key] as string) ?? value);
+ const numberOfLines = DebugUtils.getNumberOfLinesFromString((formDraftData?.[key as keyof typeof formDraftData] as string) ?? value);
return (
);
})}
- {textFields.length === 0 && None}
+ {textFields.length === 0 && {translate('debug.none')}}
{translate('debug.numberFields')}
@@ -179,7 +184,7 @@ function DebugDetails({data, onSave, onDelete, validate}: DebugDetailsProps) {
shouldInterceptSwipe
/>
))}
- {numberFields.length === 0 && None}
+ {numberFields.length === 0 && {translate('debug.none')}}
{translate('debug.constantFields')}
@@ -193,7 +198,7 @@ function DebugDetails({data, onSave, onDelete, validate}: DebugDetailsProps) {
defaultValue={String(value)}
/>
))}
- {constantFields.length === 0 && None}
+ {constantFields.length === 0 && {translate('debug.none')}}
{translate('debug.dateTimeFields')}
@@ -207,7 +212,7 @@ function DebugDetails({data, onSave, onDelete, validate}: DebugDetailsProps) {
defaultValue={String(value)}
/>
))}
- {dateTimeFields.length === 0 && None}
+ {dateTimeFields.length === 0 && {translate('debug.none')}}
{translate('debug.booleanFields')}
@@ -221,7 +226,7 @@ function DebugDetails({data, onSave, onDelete, validate}: DebugDetailsProps) {
defaultValue={value}
/>
))}
- {booleanFields.length === 0 && None}
+ {booleanFields.length === 0 && {translate('debug.none')}}
{translate('debug.hint')}
diff --git a/src/pages/Debug/Report/DebugReportPage.tsx b/src/pages/Debug/Report/DebugReportPage.tsx
index 5fc3eb21c200..530b4b5f4aec 100644
--- a/src/pages/Debug/Report/DebugReportPage.tsx
+++ b/src/pages/Debug/Report/DebugReportPage.tsx
@@ -1,27 +1,44 @@
import type {StackScreenProps} from '@react-navigation/stack';
-import React from 'react';
+import React, {useMemo} from 'react';
import {View} from 'react-native';
import {useOnyx} from 'react-native-onyx';
+import Button from '@components/Button';
import HeaderWithBackButton from '@components/HeaderWithBackButton';
import ScreenWrapper from '@components/ScreenWrapper';
import TabSelector from '@components/TabSelector/TabSelector';
+import Text from '@components/Text';
import useLocalize from '@hooks/useLocalize';
+import useStyleUtils from '@hooks/useStyleUtils';
+import useTheme from '@hooks/useTheme';
import useThemeStyles from '@hooks/useThemeStyles';
import DebugUtils from '@libs/DebugUtils';
import * as DeviceCapabilities from '@libs/DeviceCapabilities';
import Navigation from '@libs/Navigation/Navigation';
import OnyxTabNavigator, {TopTab} from '@libs/Navigation/OnyxTabNavigator';
import type {DebugParamList} from '@libs/Navigation/types';
+import * as ReportUtils from '@libs/ReportUtils';
+import SidebarUtils from '@libs/SidebarUtils';
import DebugDetails from '@pages/Debug/DebugDetails';
import DebugJSON from '@pages/Debug/DebugJSON';
import Debug from '@userActions/Debug';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
+import ROUTES from '@src/ROUTES';
import type SCREENS from '@src/SCREENS';
import DebugReportActions from './DebugReportActions';
type DebugReportPageProps = StackScreenProps;
+type Metadata = {
+ title: string;
+ subtitle: string;
+ message?: string;
+ action?: {
+ name: string;
+ callback: () => void;
+ };
+};
+
function DebugReportPage({
route: {
params: {reportID},
@@ -29,7 +46,70 @@ function DebugReportPage({
}: DebugReportPageProps) {
const {translate} = useLocalize();
const styles = useThemeStyles();
+ const StyleUtils = useStyleUtils();
+ const theme = useTheme();
const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`);
+ const [reportActions] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`);
+ const [transactionViolations] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS);
+ const [parentReportActions] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report?.parentReportID ?? '-1'}`);
+ const parentReportAction = parentReportActions && report?.parentReportID ? parentReportActions[report?.parentReportActionID ?? '-1'] : undefined;
+
+ const metadata = useMemo(() => {
+ if (!report) {
+ return [];
+ }
+
+ const reasonLHN = DebugUtils.getReasonForShowingRowInLHN(report);
+ const {reason: reasonGBR, reportAction: reportActionGBR} = DebugUtils.getReasonAndReportActionForGBRInLHNRow(report) ?? {};
+ const reportActionRBR = DebugUtils.getRBRReportAction(report, reportActions);
+ const shouldDisplayViolations = ReportUtils.shouldDisplayTransactionThreadViolations(report, transactionViolations, parentReportAction);
+ const shouldDisplayReportViolations = ReportUtils.isReportOwner(report) && ReportUtils.hasReportViolations(reportID);
+ const hasRBR = SidebarUtils.shouldShowRedBrickRoad(report, reportActions, !!shouldDisplayViolations || shouldDisplayReportViolations, transactionViolations);
+ const hasGBR = !hasRBR && !!reasonGBR;
+
+ return [
+ {
+ title: translate('debug.visibleInLHN'),
+ subtitle: translate(`debug.${!!reasonLHN}`),
+ message: reasonLHN ? translate(reasonLHN) : undefined,
+ },
+ {
+ title: translate('debug.GBR'),
+ subtitle: translate(`debug.${hasGBR}`),
+ message: hasGBR ? translate(reasonGBR) : undefined,
+ action:
+ hasGBR && reportActionGBR
+ ? {
+ name: translate('common.view'),
+ callback: () =>
+ Navigation.navigate(
+ ROUTES.REPORT_WITH_ID.getRoute(
+ reportActionGBR.childReportID ?? reportActionGBR.parentReportID ?? report.reportID,
+ reportActionGBR.childReportID ? undefined : reportActionGBR.reportActionID,
+ ),
+ ),
+ }
+ : undefined,
+ },
+ {
+ title: translate('debug.RBR'),
+ subtitle: translate(`debug.${hasRBR}`),
+ action:
+ hasRBR && reportActionRBR
+ ? {
+ name: translate('common.view'),
+ callback: () =>
+ Navigation.navigate(
+ ROUTES.REPORT_WITH_ID.getRoute(
+ reportActionRBR.childReportID ?? reportActionRBR.parentReportID ?? report.reportID,
+ reportActionRBR.childReportID ? undefined : reportActionRBR.reportActionID,
+ ),
+ ),
+ }
+ : undefined,
+ },
+ ];
+ }, [parentReportAction, report, reportActions, reportID, transactionViolations, translate]);
return (
+ >
+
+ {metadata?.map(({title, subtitle, message, action}) => (
+
+
+ {title}
+ {subtitle}
+
+ {message && {message}}
+ {action && (
+
+ )}
+
+ ))}
+
+
)}
{() => }
diff --git a/src/types/form/DebugReportActionForm.ts b/src/types/form/DebugReportActionForm.ts
index 51b5113483c1..0d8f20e5fc4b 100644
--- a/src/types/form/DebugReportActionForm.ts
+++ b/src/types/form/DebugReportActionForm.ts
@@ -103,7 +103,6 @@ type DebugReportActionForm = Form<
[INPUT_IDS.ORIGINAL_MESSAGE]: string;
[INPUT_IDS.MESSAGE]: string;
[INPUT_IDS.PREVIOUS_MESSAGE]: string;
- [key: string]: unknown;
}
>;
diff --git a/src/types/form/DebugReportForm.ts b/src/types/form/DebugReportForm.ts
index c517822befac..c3308de02122 100644
--- a/src/types/form/DebugReportForm.ts
+++ b/src/types/form/DebugReportForm.ts
@@ -97,7 +97,6 @@ type DebugReportForm = Form<
[INPUT_IDS.VISIBLE_CHAT_MEMBER_ACCOUNT_IDS]: string;
[INPUT_IDS.WELCOME_MESSAGE]: string;
[INPUT_IDS.WRITE_CAPABILITY]: ValueOf;
- [key: string]: unknown;
}
>;
diff --git a/src/types/onyx/ReportAction.ts b/src/types/onyx/ReportAction.ts
index 9fb74f415ccf..d157e82999f9 100644
--- a/src/types/onyx/ReportAction.ts
+++ b/src/types/onyx/ReportAction.ts
@@ -269,7 +269,7 @@ type ReportActionBase = OnyxCommon.OnyxValueWithOfflineFeedback<{
reportActionTimestamp?: number;
/**
- * Unix timestamp of when the report action was created, without the miliseconds (need to multiply by 1000)
+ * Unix timestamp of when the report action was created, without the milliseconds (need to multiply by 1000)
*
* Note: This is sent by the backend but we don't use it locally
*/
diff --git a/tests/unit/DebugUtilsTest.ts b/tests/unit/DebugUtilsTest.ts
index ce4983b2223c..34c2ad2bde73 100644
--- a/tests/unit/DebugUtilsTest.ts
+++ b/tests/unit/DebugUtilsTest.ts
@@ -1,7 +1,12 @@
+import Onyx from 'react-native-onyx';
import type {ObjectType} from '@libs/DebugUtils';
import DebugUtils from '@libs/DebugUtils';
import CONST from '@src/CONST';
-import type {Report, ReportAction} from '@src/types/onyx';
+import ONYXKEYS from '@src/ONYXKEYS';
+import type {Report, ReportAction, ReportActions} from '@src/types/onyx';
+import type {JoinWorkspaceResolution} from '@src/types/onyx/OriginalMessage';
+import type {ReportCollectionDataSet} from '@src/types/onyx/Report';
+import type {ReportActionsCollectionDataSet} from '@src/types/onyx/ReportAction';
import type ReportActionName from '../../src/types/onyx/ReportActionName';
import createRandomReportAction from '../utils/collections/reportActions';
import createRandomReport from '../utils/collections/reports';
@@ -648,4 +653,691 @@ describe('DebugUtils', () => {
);
});
});
+ describe('getReasonForShowingRowInLHN', () => {
+ const baseReport: Report = {
+ reportID: '1',
+ type: CONST.REPORT.TYPE.CHAT,
+ chatType: CONST.REPORT.CHAT_TYPE.POLICY_ROOM,
+ reportName: 'My first chat',
+ lastMessageText: 'Hello World!',
+ };
+ beforeAll(() => {
+ Onyx.init({
+ keys: ONYXKEYS,
+ });
+ });
+ beforeEach(() => {
+ Onyx.clear();
+ });
+ it('returns null when report is not defined', () => {
+ const reason = DebugUtils.getReasonForShowingRowInLHN(undefined);
+ expect(reason).toBeNull();
+ });
+ it('returns correct reason when report has a valid draft comment', async () => {
+ await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT_DRAFT_COMMENT}1`, 'Hello world!');
+ const reason = DebugUtils.getReasonForShowingRowInLHN(baseReport);
+ expect(reason).toBe('debug.reasonVisibleInLHN.hasDraftComment');
+ });
+ it('returns correct reason when report has GBR', () => {
+ const reason = DebugUtils.getReasonForShowingRowInLHN({
+ ...baseReport,
+ lastMentionedTime: '2024-08-10 18:70:44.171',
+ lastReadTime: '2024-08-08 18:70:44.171',
+ });
+ expect(reason).toBe('debug.reasonVisibleInLHN.hasGBR');
+ });
+ it('returns correct reason when report is pinned', () => {
+ const reason = DebugUtils.getReasonForShowingRowInLHN({
+ ...baseReport,
+ isPinned: true,
+ });
+ expect(reason).toBe('debug.reasonVisibleInLHN.pinnedByUser');
+ });
+ it('returns correct reason when report has IOU violations', async () => {
+ const threadReport = {
+ ...baseReport,
+ stateNum: CONST.REPORT.STATE_NUM.OPEN,
+ statusNum: CONST.REPORT.STATUS_NUM.OPEN,
+ parentReportID: '0',
+ parentReportActionID: '0',
+ };
+ await Onyx.multiSet({
+ [ONYXKEYS.SESSION]: {
+ accountID: 1234,
+ },
+ [`${ONYXKEYS.COLLECTION.REPORT}0` as const]: {
+ reportID: '0',
+ type: CONST.REPORT.TYPE.EXPENSE,
+ ownerAccountID: 1234,
+ },
+ [`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}0` as const]: {
+ // eslint-disable-next-line @typescript-eslint/naming-convention
+ '0': {
+ reportActionID: '0',
+ actionName: CONST.REPORT.ACTIONS.TYPE.IOU,
+ message: {
+ type: CONST.IOU.REPORT_ACTION_TYPE.CREATE,
+ IOUTransactionID: '0',
+ IOUReportID: '0',
+ },
+ },
+ },
+ [`${ONYXKEYS.COLLECTION.REPORT}1` as const]: threadReport,
+ [`${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}0` as const]: [
+ {
+ type: CONST.VIOLATION_TYPES.VIOLATION,
+ name: CONST.VIOLATIONS.MODIFIED_AMOUNT,
+ },
+ ],
+ });
+ const reason = DebugUtils.getReasonForShowingRowInLHN(threadReport);
+ expect(reason).toBe('debug.reasonVisibleInLHN.hasIOUViolations');
+ });
+ it('returns correct reason when report has add workspace room errors', () => {
+ const reason = DebugUtils.getReasonForShowingRowInLHN({
+ ...baseReport,
+ errorFields: {
+ addWorkspaceRoom: {
+ error: 'Something happened',
+ },
+ },
+ });
+ expect(reason).toBe('debug.reasonVisibleInLHN.hasAddWorkspaceRoomErrors');
+ });
+ it('returns correct reason when report is unread', async () => {
+ await Onyx.set(ONYXKEYS.NVP_PRIORITY_MODE, CONST.PRIORITY_MODE.GSD);
+ await Onyx.set(ONYXKEYS.SESSION, {
+ accountID: 1234,
+ });
+ const reason = DebugUtils.getReasonForShowingRowInLHN({
+ ...baseReport,
+ participants: {
+ // eslint-disable-next-line @typescript-eslint/naming-convention
+ 1234: {
+ notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS,
+ },
+ },
+ lastVisibleActionCreated: '2024-08-10 18:70:44.171',
+ lastReadTime: '2024-08-08 18:70:44.171',
+ lastMessageText: 'Hello world!',
+ });
+ expect(reason).toBe('debug.reasonVisibleInLHN.isUnread');
+ });
+ it('returns correct reason when report is archived', async () => {
+ await Onyx.set(ONYXKEYS.NVP_PRIORITY_MODE, CONST.PRIORITY_MODE.DEFAULT);
+ const reason = DebugUtils.getReasonForShowingRowInLHN({
+ ...baseReport,
+ // eslint-disable-next-line @typescript-eslint/naming-convention
+ private_isArchived: 'true',
+ });
+ expect(reason).toBe('debug.reasonVisibleInLHN.isArchived');
+ });
+ it('returns correct reason when report is self DM', () => {
+ const reason = DebugUtils.getReasonForShowingRowInLHN({
+ ...baseReport,
+ chatType: CONST.REPORT.CHAT_TYPE.SELF_DM,
+ });
+ expect(reason).toBe('debug.reasonVisibleInLHN.isSelfDM');
+ });
+ it('returns correct reason when report is temporarily focused', () => {
+ const reason = DebugUtils.getReasonForShowingRowInLHN(baseReport);
+ expect(reason).toBe('debug.reasonVisibleInLHN.isFocused');
+ });
+ });
+ describe('getReasonAndReportActionForGBRInLHNRow', () => {
+ beforeAll(() => {
+ Onyx.init({
+ keys: ONYXKEYS,
+ });
+ });
+ beforeEach(() => {
+ Onyx.clear();
+ });
+ it('returns undefined reason when report is not defined', () => {
+ const {reason} = DebugUtils.getReasonAndReportActionForGBRInLHNRow(undefined) ?? {};
+ expect(reason).toBeUndefined();
+ });
+ it('returns correct reason when report has a join request', async () => {
+ const MOCK_REPORT_ACTIONS = {
+ // eslint-disable-next-line @typescript-eslint/naming-convention
+ '0': {
+ reportActionID: '0',
+ actionName: CONST.REPORT.ACTIONS.TYPE.ACTIONABLE_JOIN_REQUEST,
+ created: '2024-08-08 19:70:44.171',
+ message: {
+ choice: '' as JoinWorkspaceResolution,
+ policyID: '0',
+ },
+ } as ReportAction<'ACTIONABLEJOINREQUEST'>,
+ };
+ await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}1`, MOCK_REPORT_ACTIONS);
+ const {reason} =
+ DebugUtils.getReasonAndReportActionForGBRInLHNRow({
+ reportID: '1',
+ }) ?? {};
+ expect(reason).toBe('debug.reasonGBR.hasJoinRequest');
+ });
+ it('returns correct reason when report is unread with mention', () => {
+ const {reason} =
+ DebugUtils.getReasonAndReportActionForGBRInLHNRow({
+ reportID: '1',
+ lastMentionedTime: '2024-08-10 18:70:44.171',
+ lastReadTime: '2024-08-08 18:70:44.171',
+ }) ?? {};
+ expect(reason).toBe('debug.reasonGBR.isUnreadWithMention');
+ });
+ it('returns correct reason when report has a task which is waiting for assignee to complete it', async () => {
+ await Onyx.set(ONYXKEYS.SESSION, {accountID: 12345});
+ const {reason} =
+ DebugUtils.getReasonAndReportActionForGBRInLHNRow({
+ reportID: '1',
+ type: CONST.REPORT.TYPE.TASK,
+ hasParentAccess: false,
+ managerID: 12345,
+ stateNum: CONST.REPORT.STATE_NUM.OPEN,
+ statusNum: CONST.REPORT.STATUS_NUM.OPEN,
+ }) ?? {};
+ expect(reason).toBe('debug.reasonGBR.isWaitingForAssigneeToCompleteAction');
+ });
+ it('returns correct reason when report has a child report awaiting action from the user', () => {
+ const {reason} =
+ DebugUtils.getReasonAndReportActionForGBRInLHNRow({
+ reportID: '1',
+ hasOutstandingChildRequest: true,
+ }) ?? {};
+ expect(reason).toBe('debug.reasonGBR.hasChildReportAwaitingAction');
+ });
+ it('returns undefined reason when report has no GBR', () => {
+ const {reason} =
+ DebugUtils.getReasonAndReportActionForGBRInLHNRow({
+ reportID: '1',
+ }) ?? {};
+ expect(reason).toBeUndefined();
+ });
+ it('returns undefined reportAction when report is not defined', () => {
+ const {reportAction} = DebugUtils.getReasonAndReportActionForGBRInLHNRow(undefined) ?? {};
+ expect(reportAction).toBeUndefined();
+ });
+ it('returns the report action which is a join request', async () => {
+ const MOCK_REPORT_ACTIONS = {
+ // eslint-disable-next-line @typescript-eslint/naming-convention
+ '0': {
+ reportActionID: '0',
+ actionName: CONST.REPORT.ACTIONS.TYPE.CREATED,
+ created: '2024-08-08 18:70:44.171',
+ } as ReportAction<'CREATED'>,
+ // eslint-disable-next-line @typescript-eslint/naming-convention
+ '1': {
+ reportActionID: '1',
+ actionName: CONST.REPORT.ACTIONS.TYPE.ACTIONABLE_JOIN_REQUEST,
+ created: '2024-08-08 19:70:44.171',
+ message: {
+ choice: '' as JoinWorkspaceResolution,
+ policyID: '0',
+ },
+ } as ReportAction<'ACTIONABLEJOINREQUEST'>,
+ };
+ await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}1`, MOCK_REPORT_ACTIONS);
+ const {reportAction} =
+ DebugUtils.getReasonAndReportActionForGBRInLHNRow({
+ reportID: '1',
+ }) ?? {};
+ expect(reportAction).toMatchObject(MOCK_REPORT_ACTIONS['1']);
+ });
+ it('returns the report action which is awaiting action', async () => {
+ const MOCK_REPORTS: ReportCollectionDataSet = {
+ // Chat report
+ [`${ONYXKEYS.COLLECTION.REPORT}1`]: {
+ reportID: '1',
+ policyID: '1',
+ hasOutstandingChildRequest: true,
+ chatType: CONST.REPORT.CHAT_TYPE.POLICY_EXPENSE_CHAT,
+ },
+ // IOU report
+ [`${ONYXKEYS.COLLECTION.REPORT}2`]: {
+ reportID: '2',
+ policyID: '1',
+ managerID: 12345,
+ type: CONST.REPORT.TYPE.EXPENSE,
+ stateNum: CONST.REPORT.STATE_NUM.SUBMITTED,
+ statusNum: CONST.REPORT.STATUS_NUM.SUBMITTED,
+ },
+ };
+ const MOCK_REPORT_ACTIONS: ReportActions = {
+ // eslint-disable-next-line @typescript-eslint/naming-convention
+ '0': {
+ reportActionID: '0',
+ actionName: CONST.REPORT.ACTIONS.TYPE.CREATED,
+ created: '2024-08-08 18:70:44.171',
+ },
+ // eslint-disable-next-line @typescript-eslint/naming-convention
+ '1': {
+ reportActionID: '1',
+ actionName: CONST.REPORT.ACTIONS.TYPE.REPORT_PREVIEW,
+ created: '2024-08-08 19:70:44.171',
+ childReportID: '2',
+ },
+ };
+ await Onyx.multiSet({
+ ...MOCK_REPORTS,
+ [`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}2` as const]: {
+ // eslint-disable-next-line @typescript-eslint/naming-convention
+ private_isArchived: false,
+ },
+ [`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}1` as const]: MOCK_REPORT_ACTIONS,
+ [`${ONYXKEYS.COLLECTION.POLICY}1` as const]: {
+ approvalMode: CONST.POLICY.APPROVAL_MODE.BASIC,
+ type: CONST.POLICY.TYPE.CORPORATE,
+ },
+ [ONYXKEYS.SESSION]: {
+ accountID: 12345,
+ },
+ });
+ // eslint-disable-next-line @typescript-eslint/non-nullable-type-assertion-style
+ const {reportAction} = DebugUtils.getReasonAndReportActionForGBRInLHNRow(MOCK_REPORTS[`${ONYXKEYS.COLLECTION.REPORT}1`] as Report) ?? {};
+ expect(reportAction).toMatchObject(MOCK_REPORT_ACTIONS['1']);
+ });
+ it('returns undefined report action when report has no GBR', () => {
+ const {reportAction} =
+ DebugUtils.getReasonAndReportActionForGBRInLHNRow({
+ reportID: '1',
+ }) ?? {};
+ expect(reportAction).toBeUndefined();
+ });
+ });
+ describe('getRBRReportAction', () => {
+ beforeAll(() => {
+ Onyx.init({
+ keys: ONYXKEYS,
+ });
+ });
+ beforeEach(() => {
+ Onyx.clear();
+ });
+ it('returns undefined when report has no RBR', () => {
+ const reportAction = DebugUtils.getRBRReportAction(
+ {
+ reportID: '1',
+ },
+ undefined,
+ );
+ expect(reportAction).toBeUndefined();
+ });
+ // TODO: remove '.failing' once the implementation is fixed
+ it.failing('returns parentReportAction if it is a transaction thread, the transaction is missing smart scan fields and the report is not settled', async () => {
+ const MOCK_REPORTS: ReportCollectionDataSet = {
+ [`${ONYXKEYS.COLLECTION.REPORT}1` as const]: {
+ reportID: '1',
+ parentReportID: '2',
+ parentReportActionID: '1',
+ statusNum: CONST.REPORT.STATUS_NUM.SUBMITTED,
+ },
+ [`${ONYXKEYS.COLLECTION.REPORT}2` as const]: {
+ reportID: '2',
+ },
+ };
+ const MOCK_REPORT_ACTIONS: ReportActionsCollectionDataSet = {
+ [`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}2` as const]: {
+ // eslint-disable-next-line @typescript-eslint/naming-convention
+ '1': {
+ reportActionID: '1',
+ actionName: CONST.REPORT.ACTIONS.TYPE.IOU,
+ actorAccountID: 12345,
+ created: '2024-08-08 18:20:44.171',
+ message: {
+ type: CONST.IOU.REPORT_ACTION_TYPE.CREATE,
+ amount: 10,
+ currency: CONST.CURRENCY.USD,
+ expenseReportID: '1',
+ text: 'Vacation expense',
+ IOUTransactionID: '1',
+ },
+ },
+ },
+ };
+ await Onyx.multiSet({
+ ...MOCK_REPORTS,
+ ...MOCK_REPORT_ACTIONS,
+ [ONYXKEYS.SESSION]: {
+ accountID: 12345,
+ },
+ [`${ONYXKEYS.COLLECTION.TRANSACTION}1` as const]: {
+ amount: 0,
+ modifiedAmount: 0,
+ },
+ });
+ const reportAction = DebugUtils.getRBRReportAction(
+ // eslint-disable-next-line @typescript-eslint/non-nullable-type-assertion-style
+ MOCK_REPORTS[`${ONYXKEYS.COLLECTION.REPORT}1`] as Report,
+ undefined,
+ );
+ expect(reportAction).toBe(1);
+ });
+ describe("Report has missing fields, isn't settled and it's owner is the current user", () => {
+ describe('Report is IOU', () => {
+ it('returns correct report action which has missing fields', async () => {
+ const MOCK_IOU_REPORT: Report = {
+ reportID: '1',
+ type: CONST.REPORT.TYPE.IOU,
+ statusNum: CONST.REPORT.STATUS_NUM.SUBMITTED,
+ ownerAccountID: 12345,
+ };
+ const MOCK_REPORT_ACTIONS: ReportActions = {
+ // eslint-disable-next-line @typescript-eslint/naming-convention
+ '0': {
+ actionName: CONST.REPORT.ACTIONS.TYPE.CREATED,
+ reportActionID: '0',
+ created: '2024-08-08 18:20:44.171',
+ } as ReportAction<'CREATED'>,
+ // eslint-disable-next-line @typescript-eslint/naming-convention
+ '1': {
+ actionName: CONST.REPORT.ACTIONS.TYPE.IOU,
+ reportActionID: '1',
+ message: {
+ IOUTransactionID: '2',
+ },
+ actorAccountID: 1,
+ } as ReportAction<'IOU'>,
+ // eslint-disable-next-line @typescript-eslint/naming-convention
+ '2': {
+ actionName: CONST.REPORT.ACTIONS.TYPE.IOU,
+ reportActionID: '2',
+ message: {
+ IOUTransactionID: '1',
+ },
+ actorAccountID: 1,
+ } as ReportAction<'IOU'>,
+ // eslint-disable-next-line @typescript-eslint/naming-convention
+ '3': {
+ actionName: CONST.REPORT.ACTIONS.TYPE.IOU,
+ reportActionID: '3',
+ message: {
+ IOUTransactionID: '1',
+ },
+ actorAccountID: 12345,
+ } as ReportAction<'IOU'>,
+ };
+ await Onyx.multiSet({
+ [`${ONYXKEYS.COLLECTION.TRANSACTION}1` as const]: {
+ amount: 0,
+ modifiedAmount: 0,
+ },
+ [`${ONYXKEYS.COLLECTION.REPORT}1` as const]: MOCK_IOU_REPORT,
+ [`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}1` as const]: MOCK_REPORT_ACTIONS,
+ [ONYXKEYS.SESSION]: {
+ accountID: 12345,
+ },
+ });
+ const reportAction = DebugUtils.getRBRReportAction(MOCK_IOU_REPORT, MOCK_REPORT_ACTIONS);
+ expect(reportAction).toMatchObject(MOCK_REPORT_ACTIONS['3']);
+ });
+ });
+ describe('Report is expense', () => {
+ it('returns correct report action which has missing fields', async () => {
+ const MOCK_IOU_REPORT: Report = {
+ reportID: '1',
+ type: CONST.REPORT.TYPE.EXPENSE,
+ statusNum: CONST.REPORT.STATUS_NUM.SUBMITTED,
+ ownerAccountID: 12345,
+ };
+ const MOCK_REPORT_ACTIONS: ReportActions = {
+ // eslint-disable-next-line @typescript-eslint/naming-convention
+ '0': {
+ actionName: CONST.REPORT.ACTIONS.TYPE.CREATED,
+ reportActionID: '0',
+ created: '2024-08-08 18:20:44.171',
+ } as ReportAction<'CREATED'>,
+ // eslint-disable-next-line @typescript-eslint/naming-convention
+ '1': {
+ actionName: CONST.REPORT.ACTIONS.TYPE.IOU,
+ reportActionID: '1',
+ message: {
+ IOUTransactionID: '2',
+ },
+ actorAccountID: 1,
+ } as ReportAction<'IOU'>,
+ // eslint-disable-next-line @typescript-eslint/naming-convention
+ '2': {
+ actionName: CONST.REPORT.ACTIONS.TYPE.IOU,
+ reportActionID: '2',
+ message: {
+ IOUTransactionID: '1',
+ },
+ actorAccountID: 1,
+ } as ReportAction<'IOU'>,
+ // eslint-disable-next-line @typescript-eslint/naming-convention
+ '3': {
+ actionName: CONST.REPORT.ACTIONS.TYPE.IOU,
+ reportActionID: '3',
+ message: {
+ IOUTransactionID: '1',
+ },
+ actorAccountID: 12345,
+ } as ReportAction<'IOU'>,
+ };
+ await Onyx.multiSet({
+ [`${ONYXKEYS.COLLECTION.TRANSACTION}1` as const]: {
+ amount: 0,
+ modifiedAmount: 0,
+ },
+ [`${ONYXKEYS.COLLECTION.REPORT}1` as const]: MOCK_IOU_REPORT,
+ [`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}1` as const]: MOCK_REPORT_ACTIONS,
+ [ONYXKEYS.SESSION]: {
+ accountID: 12345,
+ },
+ });
+ const reportAction = DebugUtils.getRBRReportAction(MOCK_IOU_REPORT, MOCK_REPORT_ACTIONS);
+ expect(reportAction).toMatchObject(MOCK_REPORT_ACTIONS['3']);
+ });
+ });
+ });
+ describe('There is a report action with smart scan errors', () => {
+ it('returns correct report action which is a report preview and has an error', async () => {
+ const MOCK_CHAT_REPORT: Report = {
+ reportID: '1',
+ type: CONST.REPORT.TYPE.CHAT,
+ ownerAccountID: 12345,
+ };
+ const MOCK_IOU_REPORT: Report = {
+ reportID: '2',
+ type: CONST.REPORT.TYPE.IOU,
+ statusNum: CONST.REPORT.STATUS_NUM.SUBMITTED,
+ ownerAccountID: 12345,
+ };
+ const MOCK_CHAT_REPORT_ACTIONS: ReportActions = {
+ // eslint-disable-next-line @typescript-eslint/naming-convention
+ '0': {
+ actionName: CONST.REPORT.ACTIONS.TYPE.CREATED,
+ reportActionID: '0',
+ created: '2024-08-08 18:20:44.171',
+ } as ReportAction<'CREATED'>,
+ // eslint-disable-next-line @typescript-eslint/naming-convention
+ '1': {
+ actionName: CONST.REPORT.ACTIONS.TYPE.REPORT_PREVIEW,
+ reportActionID: '3',
+ message: {
+ linkedReportID: '2',
+ },
+ actorAccountID: 1,
+ } as ReportAction<'REPORTPREVIEW'>,
+ };
+ const MOCK_IOU_REPORT_ACTIONS = {
+ // eslint-disable-next-line @typescript-eslint/naming-convention
+ '1': {
+ actionName: CONST.REPORT.ACTIONS.TYPE.IOU,
+ reportActionID: '1',
+ message: {
+ IOUTransactionID: '1',
+ },
+ actorAccountID: 12345,
+ } as ReportAction<'IOU'>,
+ };
+ await Onyx.multiSet({
+ [`${ONYXKEYS.COLLECTION.TRANSACTION}1` as const]: {
+ amount: 0,
+ modifiedAmount: 0,
+ },
+ [`${ONYXKEYS.COLLECTION.REPORT}1` as const]: MOCK_CHAT_REPORT,
+ [`${ONYXKEYS.COLLECTION.REPORT}2` as const]: MOCK_IOU_REPORT,
+ [`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}1` as const]: MOCK_CHAT_REPORT_ACTIONS,
+ [`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}2` as const]: MOCK_IOU_REPORT_ACTIONS,
+ [ONYXKEYS.SESSION]: {
+ accountID: 12345,
+ },
+ });
+ const reportAction = DebugUtils.getRBRReportAction(MOCK_CHAT_REPORT, MOCK_CHAT_REPORT_ACTIONS);
+ expect(reportAction).toMatchObject(MOCK_CHAT_REPORT_ACTIONS['1']);
+ });
+ it('returns correct report action which is a split bill and has an error', async () => {
+ const MOCK_CHAT_REPORT: Report = {
+ reportID: '1',
+ type: CONST.REPORT.TYPE.CHAT,
+ ownerAccountID: 1,
+ };
+ const MOCK_IOU_REPORT: Report = {
+ reportID: '2',
+ type: CONST.REPORT.TYPE.IOU,
+ ownerAccountID: 1,
+ };
+ const MOCK_REPORT_ACTIONS: ReportActions = {
+ // eslint-disable-next-line @typescript-eslint/naming-convention
+ '0': {
+ actionName: CONST.REPORT.ACTIONS.TYPE.CREATED,
+ reportActionID: '0',
+ created: '2024-08-08 18:20:44.171',
+ } as ReportAction<'CREATED'>,
+ // eslint-disable-next-line @typescript-eslint/naming-convention
+ '1': {
+ actionName: CONST.REPORT.ACTIONS.TYPE.IOU,
+ reportActionID: '1',
+ message: {
+ IOUTransactionID: '2',
+ },
+ actorAccountID: 1,
+ } as ReportAction<'IOU'>,
+ // eslint-disable-next-line @typescript-eslint/naming-convention
+ '2': {
+ actionName: CONST.REPORT.ACTIONS.TYPE.IOU,
+ reportActionID: '2',
+ message: {
+ IOUTransactionID: '1',
+ },
+ actorAccountID: 1,
+ } as ReportAction<'IOU'>,
+ // eslint-disable-next-line @typescript-eslint/naming-convention
+ '3': {
+ actionName: CONST.REPORT.ACTIONS.TYPE.IOU,
+ reportActionID: '3',
+ message: {
+ IOUTransactionID: '1',
+ type: CONST.IOU.REPORT_ACTION_TYPE.SPLIT,
+ },
+ actorAccountID: 1,
+ } as ReportAction<'IOU'>,
+ };
+ await Onyx.multiSet({
+ [`${ONYXKEYS.COLLECTION.TRANSACTION}1` as const]: {
+ amount: 0,
+ modifiedAmount: 0,
+ },
+ [`${ONYXKEYS.COLLECTION.REPORT}1` as const]: MOCK_CHAT_REPORT,
+ [`${ONYXKEYS.COLLECTION.REPORT}2` as const]: MOCK_IOU_REPORT,
+ [`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}1` as const]: MOCK_REPORT_ACTIONS,
+ [ONYXKEYS.SESSION]: {
+ accountID: 12345,
+ },
+ });
+ const reportAction = DebugUtils.getRBRReportAction(MOCK_CHAT_REPORT, MOCK_REPORT_ACTIONS);
+ expect(reportAction).toMatchObject(MOCK_REPORT_ACTIONS['3']);
+ });
+ it("returns undefined if there's no report action is a report preview or a split bill", async () => {
+ const MOCK_IOU_REPORT: Report = {
+ reportID: '1',
+ type: CONST.REPORT.TYPE.EXPENSE,
+ statusNum: CONST.REPORT.STATUS_NUM.SUBMITTED,
+ ownerAccountID: 12345,
+ };
+ const MOCK_REPORT_ACTIONS: ReportActions = {
+ // eslint-disable-next-line @typescript-eslint/naming-convention
+ '0': {
+ actionName: CONST.REPORT.ACTIONS.TYPE.CREATED,
+ reportActionID: '0',
+ created: '2024-08-08 18:20:44.171',
+ } as ReportAction<'CREATED'>,
+ // eslint-disable-next-line @typescript-eslint/naming-convention
+ '1': {
+ actionName: CONST.REPORT.ACTIONS.TYPE.IOU,
+ reportActionID: '1',
+ message: {
+ IOUTransactionID: '2',
+ },
+ actorAccountID: 1,
+ } as ReportAction<'IOU'>,
+ // eslint-disable-next-line @typescript-eslint/naming-convention
+ '2': {
+ actionName: CONST.REPORT.ACTIONS.TYPE.IOU,
+ reportActionID: '2',
+ message: {
+ IOUTransactionID: '1',
+ },
+ actorAccountID: 1,
+ } as ReportAction<'IOU'>,
+ // eslint-disable-next-line @typescript-eslint/naming-convention
+ '3': {
+ actionName: CONST.REPORT.ACTIONS.TYPE.IOU,
+ reportActionID: '3',
+ message: {
+ IOUTransactionID: '1',
+ },
+ actorAccountID: 12345,
+ } as ReportAction<'IOU'>,
+ };
+ await Onyx.multiSet({
+ [`${ONYXKEYS.COLLECTION.TRANSACTION}1` as const]: {
+ amount: 0,
+ modifiedAmount: 0,
+ },
+ [`${ONYXKEYS.COLLECTION.REPORT}1` as const]: MOCK_IOU_REPORT,
+ [`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}1` as const]: MOCK_REPORT_ACTIONS,
+ [ONYXKEYS.SESSION]: {
+ accountID: 12345,
+ },
+ });
+ const reportAction = DebugUtils.getRBRReportAction(MOCK_IOU_REPORT, MOCK_REPORT_ACTIONS);
+ expect(reportAction).toMatchObject(MOCK_REPORT_ACTIONS['3']);
+ });
+ });
+ it('returns report action that contains errors', () => {
+ const MOCK_REPORT_ACTIONS: ReportActions = {
+ // eslint-disable-next-line @typescript-eslint/naming-convention
+ '0': {
+ reportActionID: '0',
+ actionName: CONST.REPORT.ACTIONS.TYPE.CREATED,
+ created: '2024-08-08 18:40:44.171',
+ },
+ // eslint-disable-next-line @typescript-eslint/naming-convention
+ '1': {
+ reportActionID: '1',
+ actionName: CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT,
+ created: '2024-08-08 18:42:44.171',
+ errors: {
+ randomError: 'Random error',
+ },
+ },
+ // eslint-disable-next-line @typescript-eslint/naming-convention
+ '2': {
+ reportActionID: '2',
+ actionName: CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT,
+ created: '2024-08-08 18:44:44.171',
+ },
+ };
+ const reportAction = DebugUtils.getRBRReportAction(
+ {
+ reportID: '1',
+ },
+ MOCK_REPORT_ACTIONS,
+ );
+ expect(reportAction).toMatchObject(MOCK_REPORT_ACTIONS['1']);
+ });
+ });
});