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 && ( +