From e1f15f64ef09c7fbbf228276f97b5b0cd3cf2d5a Mon Sep 17 00:00:00 2001 From: MelvinBot Date: Wed, 28 Jan 2026 23:16:43 +0000 Subject: [PATCH 1/4] feat: Replace GBR/RBR dots with action badges in Inbox LHN This change replaces the green/red dot indicators in the Left Hand Navigation with action badges that display a verb (Submit, Approve, Pay, Export) to describe the required action for expense reports. - Add ActionBadge component for rendering action badges - Add getReportActionBadge function to determine the appropriate action - Add ACTION_BADGE_VERBS constants for action labels - Update OptionRowLHN to conditionally render ActionBadge instead of dots - Add styles for action badge dot and text Co-authored-by: David Barrett --- src/CONST/index.ts | 6 ++ src/components/ActionBadge.tsx | 36 +++++++++++ .../LHNOptionsList/OptionRowLHN.tsx | 57 +++++++++++------ .../LHNOptionsList/OptionRowLHNData.tsx | 37 +++++++++++ src/components/LHNOptionsList/types.ts | 4 ++ src/libs/ReportPrimaryActionUtils.ts | 62 +++++++++++++++++++ src/styles/index.ts | 12 ++++ 7 files changed, 196 insertions(+), 18 deletions(-) create mode 100644 src/components/ActionBadge.tsx diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 4c23cb89743f..4fa1afde40c0 100755 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -4093,6 +4093,12 @@ const CONST = { ERROR: 'error', INFO: 'info', }, + ACTION_BADGE_VERBS: { + SUBMIT: 'Submit', + APPROVE: 'Approve', + PAY: 'Pay', + EXPORT: 'Export', + }, REPORT_DETAILS_MENU_ITEM: { MEMBERS: 'member', INVITE: 'invite', diff --git a/src/components/ActionBadge.tsx b/src/components/ActionBadge.tsx new file mode 100644 index 000000000000..0e3e037d1e57 --- /dev/null +++ b/src/components/ActionBadge.tsx @@ -0,0 +1,36 @@ +import React from 'react'; +import {View} from 'react-native'; +import useTheme from '@hooks/useTheme'; +import useThemeStyles from '@hooks/useThemeStyles'; +import Text from './Text'; + +type ActionBadgeProps = { + /** The action verb to display (e.g., "Submit", "Approve", "Pay", "Export") */ + verb: string; + + /** Whether the badge should be displayed in error state (red) */ + isError?: boolean; +}; + +function ActionBadge({verb, isError = false}: ActionBadgeProps) { + const theme = useTheme(); + const styles = useThemeStyles(); + + const badgeColor = isError ? theme.danger : theme.success; + + return ( + + + {verb} + + ); +} + +ActionBadge.displayName = 'ActionBadge'; + +export default ActionBadge; diff --git a/src/components/LHNOptionsList/OptionRowLHN.tsx b/src/components/LHNOptionsList/OptionRowLHN.tsx index df0bd683c2f9..abc2fdf00b5c 100644 --- a/src/components/LHNOptionsList/OptionRowLHN.tsx +++ b/src/components/LHNOptionsList/OptionRowLHN.tsx @@ -1,6 +1,7 @@ import React, {useMemo, useRef, useState} from 'react'; import type {GestureResponderEvent, ViewStyle} from 'react-native'; import {StyleSheet, View} from 'react-native'; +import ActionBadge from '@components/ActionBadge'; import DisplayNames from '@components/DisplayNames'; import Hoverable from '@components/Hoverable'; import Icon from '@components/Icon'; @@ -55,6 +56,7 @@ function OptionRowLHN({ shouldShowRBRorGBRTooltip, isScreenFocused = false, testID, + actionBadge, }: OptionRowLHNProps) { const theme = useTheme(); const styles = useThemeStyles(); @@ -331,30 +333,49 @@ function OptionRowLHN({ {optionItem.descriptiveText} ) : null} - {brickRoadIndicator === CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR && ( - - - - )} + {brickRoadIndicator === CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR && + (actionBadge ? ( + + + + ) : ( + + + + ))} - {brickRoadIndicator === CONST.BRICK_ROAD_INDICATOR_STATUS.INFO && ( - - - - )} + {brickRoadIndicator === CONST.BRICK_ROAD_INDICATOR_STATUS.INFO && + (actionBadge ? ( + + + + ) : ( + + + + ))} {hasDraftComment && !!optionItem.isAllowedToComment && ( () => ({}) as T; + /* * This component gets the data from onyx for the actual * OptionRowLHN component. @@ -48,9 +54,15 @@ function OptionRowLHNData({ const {currentReportID: currentReportIDValue} = useCurrentReportIDState(); const isReportFocused = isOptionFocused && currentReportIDValue === reportID; const optionItemRef = useRef(undefined); + const currentUserPersonalDetails = useCurrentUserPersonalDetails(); + const currentUserLogin = currentUserPersonalDetails.login ?? ''; const [movedFromReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${getMovedReportID(lastAction, CONST.REPORT.MOVE_TYPE.FROM)}`, {canBeMissing: true}); const [movedToReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${getMovedReportID(lastAction, CONST.REPORT.MOVE_TYPE.TO)}`, {canBeMissing: true}); + + // Only fetch bankAccountList for expense reports to avoid unnecessary data fetching + const isExpenseReport = isExpenseReportUtils(fullReport); + const [bankAccountList = getEmptyObject()] = useOnyx(ONYXKEYS.BANK_ACCOUNT_LIST, {canBeMissing: true}); // Check the report errors equality to avoid re-rendering when there are no changes const prevReportErrors = usePrevious(reportAttributes?.reportErrors); const areReportErrorsEqual = useMemo(() => deepEqual(prevReportErrors, reportAttributes?.reportErrors), [prevReportErrors, reportAttributes?.reportErrors]); @@ -116,6 +128,30 @@ function OptionRowLHNData({ currentUserAccountID, ]); + // Compute action badge for expense reports + const actionBadge = useMemo(() => { + if (!isExpenseReport || !fullReport) { + return null; + } + + // Convert receiptTransactions collection to array + const reportTransactions = receiptTransactions ? Object.values(receiptTransactions).filter((t): t is NonNullable => t !== null && t !== undefined) : []; + + // Convert reportActions object to array + const reportActionsList = reportActions ? Object.values(reportActions).filter((a): a is NonNullable => a !== null && a !== undefined) : []; + + return getReportActionBadge({ + report: fullReport, + currentUserAccountID, + currentUserLogin, + bankAccountList, + policy: policy ?? undefined, + reportNameValuePairs: reportNameValuePairs ?? undefined, + reportTransactions, + reportActions: reportActionsList, + }); + }, [isExpenseReport, fullReport, receiptTransactions, reportActions, currentUserAccountID, currentUserLogin, bankAccountList, policy, reportNameValuePairs]); + return ( ); } diff --git a/src/components/LHNOptionsList/types.ts b/src/components/LHNOptionsList/types.ts index ee9256beb3f7..686c8f3ce143 100644 --- a/src/components/LHNOptionsList/types.ts +++ b/src/components/LHNOptionsList/types.ts @@ -4,6 +4,7 @@ import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; import type {ValueOf} from 'type-fest'; import type {LocaleContextProps, LocalizedTranslate} from '@components/LocaleContextProvider'; import type CONST from '@src/CONST'; +import type {ActionBadge} from '@src/libs/ReportPrimaryActionUtils'; import type {OptionData} from '@src/libs/ReportUtils'; import type { Locale, @@ -208,6 +209,9 @@ type OptionRowLHNProps = { /** The testID of the row */ testID: number; + + /** The action badge to display for expense reports (e.g., Submit, Approve, Pay, Export) */ + actionBadge?: ActionBadge | null; }; type RenderItemProps = {item: Report; index: number}; diff --git a/src/libs/ReportPrimaryActionUtils.ts b/src/libs/ReportPrimaryActionUtils.ts index 85efe924a438..5b5157e30793 100644 --- a/src/libs/ReportPrimaryActionUtils.ts +++ b/src/libs/ReportPrimaryActionUtils.ts @@ -544,6 +544,65 @@ function getTransactionThreadPrimaryAction( return ''; } +type ActionBadge = { + verb: ValueOf; +}; + +type GetReportActionBadgeParams = { + report: Report | undefined; + currentUserAccountID: number; + currentUserLogin: string; + bankAccountList: OnyxEntry; + policy?: Policy; + reportNameValuePairs?: ReportNameValuePairs; + reportTransactions: Transaction[]; + reportActions?: ReportAction[]; +}; + +/** + * Determines which action badge (if any) should be shown for a report in the LHN. + * Checks actions in workflow order from furthest down to earliest: + * Export (reimbursed) > Pay (approved) > Approve (submitted) > Submit (open) + */ +function getReportActionBadge({ + report, + currentUserAccountID, + currentUserLogin, + bankAccountList, + policy, + reportNameValuePairs, + reportTransactions, + reportActions, +}: GetReportActionBadgeParams): ActionBadge | null { + if (!report) { + return null; + } + + // Only show action badges for expense reports + if (!isExpenseReportUtils(report)) { + return null; + } + + // Check in workflow order (furthest down first): Export > Pay > Approve > Submit + if (isExportAction(report, currentUserLogin, policy, reportActions)) { + return {verb: CONST.ACTION_BADGE_VERBS.EXPORT}; + } + + if (isPrimaryPayAction(report, currentUserAccountID, currentUserLogin, bankAccountList, policy, reportNameValuePairs)) { + return {verb: CONST.ACTION_BADGE_VERBS.PAY}; + } + + if (isApproveAction(report, reportTransactions, currentUserAccountID, policy)) { + return {verb: CONST.ACTION_BADGE_VERBS.APPROVE}; + } + + if (isSubmitAction(report, reportTransactions, policy, reportNameValuePairs)) { + return {verb: CONST.ACTION_BADGE_VERBS.SUBMIT}; + } + + return null; +} + export { getReportPrimaryAction, getTransactionThreadPrimaryAction, @@ -556,4 +615,7 @@ export { isPrimaryMarkAsResolvedAction, getAllExpensesToHoldIfApplicable, isReviewDuplicatesAction, + getReportActionBadge, }; + +export type {ActionBadge}; diff --git a/src/styles/index.ts b/src/styles/index.ts index 32a31b15dec5..883877d38696 100644 --- a/src/styles/index.ts +++ b/src/styles/index.ts @@ -1079,6 +1079,18 @@ const staticStyles = (theme: ThemeColors) => fontSize: variables.fontSizeExtraSmall, }, + actionBadgeDot: { + width: 6, + height: 6, + borderRadius: 3, + }, + + actionBadgeText: { + fontSize: variables.fontSizeSmall, + ...FontUtils.fontFamily.platform.EXP_NEUE_BOLD, + ...whiteSpace.noWrap, + }, + border: { borderWidth: 1, borderRadius: variables.componentBorderRadius, From cea92d5655a2f75284e383f3b037b93ddf8d98df Mon Sep 17 00:00:00 2001 From: MelvinBot Date: Thu, 29 Jan 2026 23:44:40 +0000 Subject: [PATCH 2/4] fix: Address review comments for action badges 1. Localize action badge labels - Changed ACTION_BADGE_VERBS from hardcoded English strings to translation keys, and added translation lookup in ActionBadge component using useLocalize hook 2. Optimize bankAccountList subscription - Added conditional selector for useOnyx to return empty object for non-expense reports, preventing unnecessary subscriptions and re-renders when bank account data changes Co-authored-by: Jack Nam --- src/CONST/index.ts | 8 ++++---- src/components/ActionBadge.tsx | 17 +++++++++++++++-- .../LHNOptionsList/OptionRowLHNData.tsx | 8 ++++++-- 3 files changed, 25 insertions(+), 8 deletions(-) diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 4fa1afde40c0..1e26cba4884f 100755 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -4094,10 +4094,10 @@ const CONST = { INFO: 'info', }, ACTION_BADGE_VERBS: { - SUBMIT: 'Submit', - APPROVE: 'Approve', - PAY: 'Pay', - EXPORT: 'Export', + SUBMIT: 'submit', + APPROVE: 'approve', + PAY: 'pay', + EXPORT: 'export', }, REPORT_DETAILS_MENU_ITEM: { MEMBERS: 'member', diff --git a/src/components/ActionBadge.tsx b/src/components/ActionBadge.tsx index 0e3e037d1e57..7b1f5ba71565 100644 --- a/src/components/ActionBadge.tsx +++ b/src/components/ActionBadge.tsx @@ -1,22 +1,35 @@ import React from 'react'; import {View} from 'react-native'; +import useLocalize from '@hooks/useLocalize'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; +import type {TranslationPaths} from '@src/languages/types'; import Text from './Text'; type ActionBadgeProps = { - /** The action verb to display (e.g., "Submit", "Approve", "Pay", "Export") */ + /** The translation key for the action verb (e.g., "submit", "approve", "pay", "export") */ verb: string; /** Whether the badge should be displayed in error state (red) */ isError?: boolean; }; +/** Maps action badge verb keys to their translation paths */ +const VERB_TRANSLATION_MAP: Record = { + submit: 'common.submit', + approve: 'search.bulkActions.approve', + pay: 'iou.pay', + export: 'common.export', +}; + function ActionBadge({verb, isError = false}: ActionBadgeProps) { const theme = useTheme(); const styles = useThemeStyles(); + const {translate} = useLocalize(); const badgeColor = isError ? theme.danger : theme.success; + const translationKey = VERB_TRANSLATION_MAP[verb]; + const translatedVerb = translationKey ? translate(translationKey) : verb; return ( @@ -26,7 +39,7 @@ function ActionBadge({verb, isError = false}: ActionBadgeProps) { {backgroundColor: badgeColor}, ]} /> - {verb} + {translatedVerb} ); } diff --git a/src/components/LHNOptionsList/OptionRowLHNData.tsx b/src/components/LHNOptionsList/OptionRowLHNData.tsx index 0efba5d0b5a3..d632c9940f56 100644 --- a/src/components/LHNOptionsList/OptionRowLHNData.tsx +++ b/src/components/LHNOptionsList/OptionRowLHNData.tsx @@ -60,9 +60,13 @@ function OptionRowLHNData({ const [movedFromReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${getMovedReportID(lastAction, CONST.REPORT.MOVE_TYPE.FROM)}`, {canBeMissing: true}); const [movedToReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${getMovedReportID(lastAction, CONST.REPORT.MOVE_TYPE.TO)}`, {canBeMissing: true}); - // Only fetch bankAccountList for expense reports to avoid unnecessary data fetching + // Only fetch bankAccountList for expense reports to avoid unnecessary data fetching and re-renders const isExpenseReport = isExpenseReportUtils(fullReport); - const [bankAccountList = getEmptyObject()] = useOnyx(ONYXKEYS.BANK_ACCOUNT_LIST, {canBeMissing: true}); + const [bankAccountList = getEmptyObject()] = useOnyx(ONYXKEYS.BANK_ACCOUNT_LIST, { + canBeMissing: true, + // Return empty object for non-expense reports to prevent unnecessary subscriptions + selector: isExpenseReport ? undefined : () => getEmptyObject(), + }); // Check the report errors equality to avoid re-rendering when there are no changes const prevReportErrors = usePrevious(reportAttributes?.reportErrors); const areReportErrorsEqual = useMemo(() => deepEqual(prevReportErrors, reportAttributes?.reportErrors), [prevReportErrors, reportAttributes?.reportErrors]); From 71b1a5012411ec7e2406d93dbe1434a4bcf6df60 Mon Sep 17 00:00:00 2001 From: MelvinBot Date: Fri, 30 Jan 2026 00:01:24 +0000 Subject: [PATCH 3/4] fix: Update function calls to match new signatures after rebase - Add reportMetadata parameter to isApproveAction and isSubmitAction calls - Fix prettier formatting in ActionBadge.tsx --- src/components/ActionBadge.tsx | 7 +------ src/libs/ReportPrimaryActionUtils.ts | 4 ++-- 2 files changed, 3 insertions(+), 8 deletions(-) diff --git a/src/components/ActionBadge.tsx b/src/components/ActionBadge.tsx index 7b1f5ba71565..cce38ca35741 100644 --- a/src/components/ActionBadge.tsx +++ b/src/components/ActionBadge.tsx @@ -33,12 +33,7 @@ function ActionBadge({verb, isError = false}: ActionBadgeProps) { return ( - + {translatedVerb} ); diff --git a/src/libs/ReportPrimaryActionUtils.ts b/src/libs/ReportPrimaryActionUtils.ts index 5b5157e30793..3b81a55147c9 100644 --- a/src/libs/ReportPrimaryActionUtils.ts +++ b/src/libs/ReportPrimaryActionUtils.ts @@ -592,11 +592,11 @@ function getReportActionBadge({ return {verb: CONST.ACTION_BADGE_VERBS.PAY}; } - if (isApproveAction(report, reportTransactions, currentUserAccountID, policy)) { + if (isApproveAction(report, reportTransactions, currentUserAccountID, undefined, policy)) { return {verb: CONST.ACTION_BADGE_VERBS.APPROVE}; } - if (isSubmitAction(report, reportTransactions, policy, reportNameValuePairs)) { + if (isSubmitAction(report, reportTransactions, undefined, policy, reportNameValuePairs)) { return {verb: CONST.ACTION_BADGE_VERBS.SUBMIT}; } From 30f596b035daa194bb276b89f06a336b6bec0080 Mon Sep 17 00:00:00 2001 From: MelvinBot Date: Fri, 30 Jan 2026 00:07:15 +0000 Subject: [PATCH 4/4] fix: Resolve TypeScript errors in getReportActionBadge Update GetReportActionBadgeParams to accept OnyxEntry types for policy and reportNameValuePairs to match how they're passed from OptionRowLHNData. Use nullish coalescing when passing to helper functions that expect Policy | undefined rather than Policy | null | undefined. Co-authored-by: Jack Nam --- src/components/LHNOptionsList/OptionRowLHNData.tsx | 4 ++-- src/libs/ReportPrimaryActionUtils.ts | 12 ++++++------ 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/components/LHNOptionsList/OptionRowLHNData.tsx b/src/components/LHNOptionsList/OptionRowLHNData.tsx index d632c9940f56..ca927719cbda 100644 --- a/src/components/LHNOptionsList/OptionRowLHNData.tsx +++ b/src/components/LHNOptionsList/OptionRowLHNData.tsx @@ -149,8 +149,8 @@ function OptionRowLHNData({ currentUserAccountID, currentUserLogin, bankAccountList, - policy: policy ?? undefined, - reportNameValuePairs: reportNameValuePairs ?? undefined, + policy, + reportNameValuePairs, reportTransactions, reportActions: reportActionsList, }); diff --git a/src/libs/ReportPrimaryActionUtils.ts b/src/libs/ReportPrimaryActionUtils.ts index 3b81a55147c9..968452ebfb14 100644 --- a/src/libs/ReportPrimaryActionUtils.ts +++ b/src/libs/ReportPrimaryActionUtils.ts @@ -553,8 +553,8 @@ type GetReportActionBadgeParams = { currentUserAccountID: number; currentUserLogin: string; bankAccountList: OnyxEntry; - policy?: Policy; - reportNameValuePairs?: ReportNameValuePairs; + policy: OnyxEntry; + reportNameValuePairs: OnyxEntry; reportTransactions: Transaction[]; reportActions?: ReportAction[]; }; @@ -584,19 +584,19 @@ function getReportActionBadge({ } // Check in workflow order (furthest down first): Export > Pay > Approve > Submit - if (isExportAction(report, currentUserLogin, policy, reportActions)) { + if (isExportAction(report, currentUserLogin, policy ?? undefined, reportActions)) { return {verb: CONST.ACTION_BADGE_VERBS.EXPORT}; } - if (isPrimaryPayAction(report, currentUserAccountID, currentUserLogin, bankAccountList, policy, reportNameValuePairs)) { + if (isPrimaryPayAction(report, currentUserAccountID, currentUserLogin, bankAccountList, policy ?? undefined, reportNameValuePairs ?? undefined)) { return {verb: CONST.ACTION_BADGE_VERBS.PAY}; } - if (isApproveAction(report, reportTransactions, currentUserAccountID, undefined, policy)) { + if (isApproveAction(report, reportTransactions, currentUserAccountID, undefined, policy ?? undefined)) { return {verb: CONST.ACTION_BADGE_VERBS.APPROVE}; } - if (isSubmitAction(report, reportTransactions, undefined, policy, reportNameValuePairs)) { + if (isSubmitAction(report, reportTransactions, undefined, policy ?? undefined, reportNameValuePairs ?? undefined)) { return {verb: CONST.ACTION_BADGE_VERBS.SUBMIT}; }