From 852e9dcea9a20e430b64133bf645a1210c184f81 Mon Sep 17 00:00:00 2001 From: Pedro Guerreiro Date: Fri, 30 Aug 2024 15:10:08 +0100 Subject: [PATCH 01/18] feat: add reasons for showing reports in LHN --- src/languages/en.ts | 18 + src/languages/es.ts | 18 + src/libs/DebugUtils.ts | 227 ++++++- src/libs/ReportUtils.ts | 32 +- src/pages/Debug/DebugDetails.tsx | 2 +- src/pages/Debug/Report/DebugReportPage.tsx | 92 ++- tests/unit/DebugUtilsTest.ts | 694 ++++++++++++++++++++- 7 files changed, 1069 insertions(+), 14 deletions(-) diff --git a/src/languages/en.ts b/src/languages/en.ts index bd5ff405529e..6ba9b8a1934f 100755 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -4827,5 +4827,23 @@ export default { date: 'Date', time: 'Time', none: 'None', + reasonVisibleInLHN: { + hasDraftComment: 'Has draft comment', + hasGBR: 'Has GBR', + pinnedByUser: 'Pinned by user', + isNonReimbursedIOU: 'Is non reimbursed IOU', + 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', + }, }, } satisfies TranslationBase; diff --git a/src/languages/es.ts b/src/languages/es.ts index 2f11de46faed..e99f8c5e6fea 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -5344,5 +5344,23 @@ export default { date: 'Fecha', time: 'Hora', none: 'Ninguno', + reasonVisibleInLHN: { + hasDraftComment: 'Tiene comentario en borrador', + hasGBR: 'Tiene GBR', + pinnedByUser: 'Fijado por el usuario', + isNonReimbursedIOU: 'Es un IOU no reembolsado', + 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 DM 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', + }, }, } satisfies EnglishTranslation; diff --git a/src/libs/DebugUtils.ts b/src/libs/DebugUtils.ts index 3efba2dcd161..dfd4219e8c6c 100644 --- a/src/libs/DebugUtils.ts +++ b/src/libs/DebugUtils.ts @@ -1,9 +1,20 @@ /* 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 {Report, ReportAction, ReportActions, Transaction} from '@src/types/onyx'; +import type {JoinWorkspaceResolution} from '@src/types/onyx/OriginalMessage'; +import {isEmptyObject} from '@src/types/utils/EmptyObject'; +import * as IOU from './actions/IOU'; +import {hasValidDraftComment} from './DraftCommentUtils'; +import * as PolicyUtils from './PolicyUtils'; +import * as ReportActionsUtils from './ReportActionsUtils'; +import * as ReportUtils from './ReportUtils'; +import * as TransactionUtils from './TransactionUtils'; class NumberError extends SyntaxError { constructor() { @@ -107,6 +118,56 @@ const REPORT_ACTION_BOOLEAN_PROPERTIES: Array = [ const REPORT_ACTION_DATE_PROPERTIES: Array = ['created', 'lastModified'] satisfies Array; +let isInFocusMode: boolean | undefined; +Onyx.connect({ + key: ONYXKEYS.NVP_PRIORITY_MODE, + callback: (priorityMode) => { + isInFocusMode = priorityMode === CONST.PRIORITY_MODE.GSD; + }, +}); + +let allTransactions: OnyxCollection = {}; +Onyx.connect({ + key: ONYXKEYS.COLLECTION.TRANSACTION, + waitForCollectionCallback: true, + callback: (value) => { + if (!value) { + return; + } + + allTransactions = Object.keys(value) + .filter((key) => !!value[key]) + .reduce((result: OnyxCollection, key) => { + if (result) { + // eslint-disable-next-line no-param-reassign + result[key] = value[key]; + } + return result; + }, {}); + }, +}); + +let allReportActions: OnyxCollection; +Onyx.connect({ + key: ONYXKEYS.COLLECTION.REPORT_ACTIONS, + waitForCollectionCallback: true, + callback: (actions) => { + if (!actions) { + return; + } + + allReportActions = actions ?? {}; + }, +}); + +let currentUserAccountID: number | undefined; +Onyx.connect({ + key: ONYXKEYS.SESSION, + callback: (value) => { + currentUserAccountID = value?.accountID; + }, +}); + function stringifyJSON(data: Record) { return JSON.stringify(data, null, 6); } @@ -551,6 +612,164 @@ function validateReportActionJSON(json: string) { }); } +/** + * Gets the reason for showing LHN row + * + * @param report + * @returns translation key or null + */ +function getReasonForShowingRowInLHN(report: OnyxEntry): TranslationPaths | null { + if (!report) { + return null; + } + + const isInDefaultMode = !isInFocusMode; + + if (hasValidDraftComment(report.reportID)) { + return 'debug.reasonVisibleInLHN.hasDraftComment'; + } + + if (ReportUtils.requiresAttentionFromCurrentUser(report)) { + return 'debug.reasonVisibleInLHN.hasGBR'; + } + + if (report.isPinned) { + return 'debug.reasonVisibleInLHN.pinnedByUser'; + } + + const reportIsSettled = report.statusNum === CONST.REPORT.STATUS_NUM.REIMBURSED; + + if (ReportUtils.isExpenseRequest(report) && !reportIsSettled) { + return 'debug.reasonVisibleInLHN.isNonReimbursedIOU'; + } + + if (report.errorFields?.addWorkspaceRoom) { + return 'debug.reasonVisibleInLHN.hasAddWorkspaceRoomErrors'; + } + + if (isInFocusMode && ReportUtils.isUnread(report) && ReportUtils.getReportNotificationPreference(report) !== CONST.REPORT.NOTIFICATION_PREFERENCE.MUTE) { + return 'debug.reasonVisibleInLHN.isUnread'; + } + + if (isInDefaultMode && ReportUtils.isArchivedRoom(report, ReportUtils.getReportNameValuePairs(report?.reportID))) { + return 'debug.reasonVisibleInLHN.isArchived'; + } + + if (ReportUtils.isSelfDM(report)) { + return 'debug.reasonVisibleInLHN.isSelfDM'; + } + + return 'debug.reasonVisibleInLHN.isFocused'; +} + +/** + * Gets the reason that is causing the GBR to show up in LHN row + * + * @param report + * @param parentReportAction + * @returns translation key or null + */ +function getReasonForShowingGreenDotInLHNRow(report: OnyxEntry, parentReportAction?: ReportAction): TranslationPaths | null { + if (!report) { + return null; + } + + if (ReportUtils.isJoinRequestInAdminRoom(report)) { + return 'debug.reasonGBR.hasJoinRequest'; + } + + if (ReportUtils.isUnreadWithMention(report)) { + return 'debug.reasonGBR.isUnreadWithMention'; + } + + if (ReportUtils.isWaitingForAssigneeToCompleteAction(report, parentReportAction)) { + return 'debug.reasonGBR.isWaitingForAssigneeToCompleteAction'; + } + + // Has a child report that is awaiting action (e.g. approve, pay, add bank account) from current user + if (report.hasOutstandingChildRequest) { + return 'debug.reasonGBR.hasChildReportAwaitingAction'; + } + + const invoiceRoomReportActions = ReportActionsUtils.getAllReportActions(report.reportID); + const hasMissinginvoiceBankAccountInInvoiceRoom = + ReportUtils.isInvoiceRoom(report) && + Object.values(invoiceRoomReportActions).some( + (reportAction) => + reportAction.actionName === CONST.REPORT.ACTIONS.TYPE.REPORT_PREVIEW && reportAction.childReportID && ReportUtils.hasMissingInvoiceBankAccount(reportAction.childReportID), + ); + if (ReportUtils.hasMissingInvoiceBankAccount(report.reportID) || hasMissinginvoiceBankAccountInInvoiceRoom) { + return 'debug.reasonGBR.hasMissingInvoiceBankAccount'; + } + + return null; +} + +/** + * Gets the report action that is causing the GBR to show up in LHN + * + * @param report + * @param reportActions + * @returns report action + */ +function getGBRReportAction(report: OnyxEntry, reportActions: OnyxEntry): OnyxEntry { + if (!report) { + return undefined; + } + + if (ReportUtils.isJoinRequestInAdminRoom(report)) { + return Object.values(reportActions ?? {}).find( + (reportActionItem) => + ReportActionsUtils.isActionableJoinRequest(reportActionItem) && ReportActionsUtils.getOriginalMessage(reportActionItem)?.choice === ('' as JoinWorkspaceResolution), + ); + } + + // Has a child report that is awaiting action (e.g. approve, pay, add bank account) from current user + if (report.hasOutstandingChildRequest) { + return Object.values(reportActions ?? {}).find((action) => { + const iouReport = ReportUtils.getReportOrDraftReport(action.childReportID ?? '-1'); + const policy = PolicyUtils.getPolicy(iouReport?.policyID); + const shouldShowSettlementButton = IOU.canIOUBePaid(iouReport, report, policy) || IOU.canApproveIOU(iouReport, policy); + return action.actionName === CONST.REPORT.ACTIONS.TYPE.REPORT_PREVIEW && shouldShowSettlementButton; + }); + } + + return undefined; +} + +/** + * Gets the report action that is causing the RBR to show up in LHN + * + * Based on OptionListUtils.getAllReportErrors + * + * @param report + * @param reportActions + * @returns report action + */ +function getRBRReportAction(report: OnyxEntry, reportActions: OnyxEntry): OnyxEntry { + const parentReportAction: OnyxEntry = + !report?.parentReportID || !report?.parentReportActionID ? undefined : allReportActions?.[report.parentReportID ?? '-1']?.[report.parentReportActionID ?? '-1']; + + const reportActionsValues = Object.values(reportActions ?? {}); + + // TODO: This branch is never reached because parentReportAction is undefined + if (ReportActionsUtils.wasActionTakenByCurrentUser(parentReportAction) && ReportActionsUtils.isTransactionThread(parentReportAction)) { + const transactionID = ReportActionsUtils.isMoneyRequestAction(parentReportAction) ? ReportActionsUtils.getOriginalMessage(parentReportAction)?.IOUTransactionID : null; + const transaction = allTransactions?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`]; + if (TransactionUtils.hasMissingSmartscanFields(transaction ?? null) && !ReportUtils.isSettled(transaction?.reportID)) { + return undefined; + } + } else if ((ReportUtils.isIOUReport(report) || ReportUtils.isExpenseReport(report)) && report?.ownerAccountID === currentUserAccountID) { + if (ReportUtils.shouldShowRBRForMissingSmartscanFields(report?.reportID ?? '-1') && !ReportUtils.isSettled(report?.reportID)) { + return ReportUtils.getReportActionWithMissingSmartscanFields(report?.reportID ?? '-1'); + } + } else if (ReportUtils.hasSmartscanError(reportActionsValues)) { + return ReportUtils.getReportActionWithSmartscanError(reportActionsValues); + } + + return reportActionsValues.find((action) => action && !isEmptyObject(action.errors)); +} + const DebugUtils = { stringifyJSON, onyxDataToDraftData, @@ -568,6 +787,10 @@ const DebugUtils = { validateReportDraftProperty, validateReportActionDraftProperty, validateReportActionJSON, + getReasonForShowingRowInLHN, + getReasonForShowingGreenDotInLHNRow, + getGBRReportAction, + getRBRReportAction, REPORT_ACTION_REQUIRED_PROPERTIES, REPORT_REQUIRED_PROPERTIES, }; diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index c18b6c41b420..295109d7e992 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -3217,11 +3217,14 @@ function hasMissingSmartscanFields(iouReportID: string): boolean { } /** - * Check if iouReportID has required missing fields + * Get report action which is missing smartscan fields + * + * @param iouReportID + * @returns ReportAction */ -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; } @@ -3236,6 +3239,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. */ @@ -7121,11 +7131,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) { @@ -7145,6 +7152,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; @@ -8004,6 +8018,7 @@ export { hasOnlyHeldExpenses, hasOnlyTransactionsWithPendingRoutes, hasReportNameError, + getReportActionWithSmartscanError, hasSmartscanError, hasUpdatedTotal, hasViolations, @@ -8112,6 +8127,7 @@ export { shouldReportBeInOptionList, shouldReportShowSubscript, shouldShowFlagComment, + getReportActionWithMissingSmartscanFields, shouldShowRBRForMissingSmartscanFields, shouldUseFullTitleToDisplay, updateOptimisticParentReportAction, diff --git a/src/pages/Debug/DebugDetails.tsx b/src/pages/Debug/DebugDetails.tsx index 28bcb4e0df8b..af10a58f4e59 100644 --- a/src/pages/Debug/DebugDetails.tsx +++ b/src/pages/Debug/DebugDetails.tsx @@ -38,7 +38,7 @@ type DebugDetailsProps = { validate: (key: never, 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); diff --git a/src/pages/Debug/Report/DebugReportPage.tsx b/src/pages/Debug/Report/DebugReportPage.tsx index 5fc3eb21c200..1bcc7e237553 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 {getAllReportErrors} from '@libs/OptionsListUtils'; 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 {isEmptyObject} from '@src/types/utils/EmptyObject'; 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,62 @@ 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 metadata = useMemo(() => { + if (!report) { + return []; + } + + const reasonLHN = DebugUtils.getReasonForShowingRowInLHN(report); + const reasonGBR = DebugUtils.getReasonForShowingGreenDotInLHNRow(report); + const reportActionGBR = DebugUtils.getGBRReportAction(report, reportActions); + const reportActionRBR = DebugUtils.getRBRReportAction(report, reportActions); + + return [ + { + title: 'Visible in LHN', + subtitle: String(!!reasonLHN), + message: reasonLHN ? translate(reasonLHN) : undefined, + }, + { + title: 'GBR', + subtitle: String(!!reasonGBR), + message: reasonGBR ? translate(reasonGBR) : undefined, + action: reportActionGBR + ? { + name: 'View', + callback: () => + Navigation.navigate( + ROUTES.REPORT_WITH_ID.getRoute( + reportActionGBR.childReportID ?? reportActionGBR.parentReportID ?? report.reportID, + reportActionGBR.childReportID ? undefined : reportActionGBR.reportActionID, + ), + ), + } + : undefined, + }, + { + title: 'RBR', + subtitle: String(!isEmptyObject(getAllReportErrors(report, reportActions))), + action: reportActionRBR + ? { + name: 'View cause', + callback: () => + Navigation.navigate( + ROUTES.REPORT_WITH_ID.getRoute( + reportActionRBR.childReportID ?? reportActionRBR.parentReportID ?? report.reportID, + reportActionRBR.childReportID ? undefined : reportActionRBR.reportActionID, + ), + ), + } + : undefined, + }, + ]; + }, [report, reportActions, translate]); return ( + > + {metadata?.map(({title, subtitle, message, action}) => ( + + + {title} + {subtitle} + + {message && {message}} + {action && ( +