From e0c0dfc0042749d4202049175a775c3ff9663644 Mon Sep 17 00:00:00 2001 From: Bernhard Owen Josephus Date: Sat, 14 Feb 2026 12:30:15 +0800 Subject: [PATCH 001/435] replace with connectWithoutView --- src/libs/actions/Report/DeleteReport.ts | 66 +++++++++++++++++++++++++ src/libs/actions/Report/index.ts | 42 +--------------- src/libs/actions/ReportActions.ts | 2 +- tests/actions/IOUTest.ts | 3 +- 4 files changed, 70 insertions(+), 43 deletions(-) create mode 100644 src/libs/actions/Report/DeleteReport.ts diff --git a/src/libs/actions/Report/DeleteReport.ts b/src/libs/actions/Report/DeleteReport.ts new file mode 100644 index 000000000000..60e55c2e0092 --- /dev/null +++ b/src/libs/actions/Report/DeleteReport.ts @@ -0,0 +1,66 @@ +import Onyx from 'react-native-onyx'; +import type {OnyxCollection} from 'react-native-onyx'; +import Log from '@libs/Log'; +import {getOriginalMessage, isMoneyRequestAction} from '@libs/ReportActionsUtils'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {Report, ReportAction, ReportActions} from '@src/types/onyx'; + +// We use connectWithoutView because deleteReport doesn't affect the UI rendering +// and this avoids unnecessary re-rendering because we recursively delete the report and its children +// which requires us to subscribe to the whole report and report actions collection. +let allReports: OnyxCollection; +Onyx.connectWithoutView({ + key: ONYXKEYS.COLLECTION.REPORT, + waitForCollectionCallback: true, + callback: (value) => (allReports = value), +}); + +let allReportActions: OnyxCollection; +Onyx.connectWithoutView({ + key: ONYXKEYS.COLLECTION.REPORT_ACTIONS, + waitForCollectionCallback: true, + callback: (value) => (allReportActions = value), +}); + +/** Deletes a report, along with its reportActions, any linked reports, and any linked IOU report. */ +function deleteReport(reportID: string | undefined, shouldDeleteChildReports = false) { + if (!reportID) { + Log.warn('[Report] deleteReport called with no reportID'); + return; + } + const report = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${reportID}`]; + const onyxData: Record = { + [`${ONYXKEYS.COLLECTION.REPORT}${reportID}`]: null, + [`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`]: null, + }; + + // Delete linked transactions + const reportActionsForReport = allReportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`]; + + const transactionIDs = Object.values(reportActionsForReport ?? {}) + .filter((reportAction): reportAction is ReportAction => isMoneyRequestAction(reportAction)) + .map((reportAction) => getOriginalMessage(reportAction)?.IOUTransactionID); + + for (const transactionID of [...new Set(transactionIDs)]) { + onyxData[`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`] = null; + } + + Onyx.multiSet(onyxData); + + if (shouldDeleteChildReports) { + for (const reportAction of Object.values(reportActionsForReport ?? {})) { + if (!reportAction.childReportID) { + continue; + } + deleteReport(reportAction.childReportID, shouldDeleteChildReports); + } + } + + // Delete linked IOU report + if (report?.iouReportID) { + deleteReport(report.iouReportID, shouldDeleteChildReports); + } +} + +export default deleteReport; diff --git a/src/libs/actions/Report/index.ts b/src/libs/actions/Report/index.ts index dd0ad4dd74c8..cad7efccfe6a 100644 --- a/src/libs/actions/Report/index.ts +++ b/src/libs/actions/Report/index.ts @@ -219,6 +219,7 @@ import type {Message, ReportActions} from '@src/types/onyx/ReportAction'; import type {FileObject} from '@src/types/utils/Attachment'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; import type {Dimensions} from '@src/types/utils/Layout'; +import deleteReport from './DeleteReport'; type SubscriberCallback = (isFromCurrentUser: boolean, reportAction: ReportAction | undefined) => void; @@ -3357,46 +3358,6 @@ function addPolicyReport(policyReport: OptimisticChatReport) { Navigation.dismissModalWithReport({reportID: policyReport.reportID}); } -/** Deletes a report, along with its reportActions, any linked reports, and any linked IOU report. */ -function deleteReport(reportID: string | undefined, shouldDeleteChildReports = false) { - if (!reportID) { - Log.warn('[Report] deleteReport called with no reportID'); - return; - } - const report = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${reportID}`]; - const onyxData: Record = { - [`${ONYXKEYS.COLLECTION.REPORT}${reportID}`]: null, - [`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`]: null, - }; - - // Delete linked transactions - const reportActionsForReport = allReportActions?.[reportID]; - - const transactionIDs = Object.values(reportActionsForReport ?? {}) - .filter((reportAction): reportAction is ReportAction => ReportActionsUtils.isMoneyRequestAction(reportAction)) - .map((reportAction) => ReportActionsUtils.getOriginalMessage(reportAction)?.IOUTransactionID); - - for (const transactionID of [...new Set(transactionIDs)]) { - onyxData[`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`] = null; - } - - Onyx.multiSet(onyxData); - - if (shouldDeleteChildReports) { - for (const reportAction of Object.values(reportActionsForReport ?? {})) { - if (!reportAction.childReportID) { - continue; - } - deleteReport(reportAction.childReportID, shouldDeleteChildReports); - } - } - - // Delete linked IOU report - if (report?.iouReportID) { - deleteReport(report.iouReportID, shouldDeleteChildReports); - } -} - /** * @param reportID The reportID of the policy report (workspace room) */ @@ -6640,7 +6601,6 @@ export { clearReportFieldKeyErrors, completeOnboarding, createNewReport, - deleteReport, deleteReportActionDraft, deleteReportComment, deleteReportField, diff --git a/src/libs/actions/ReportActions.ts b/src/libs/actions/ReportActions.ts index 739e87d7625c..f856a0d40589 100644 --- a/src/libs/actions/ReportActions.ts +++ b/src/libs/actions/ReportActions.ts @@ -1,12 +1,12 @@ import type {OnyxCollection} from 'react-native-onyx'; import Onyx from 'react-native-onyx'; +import deleteReport from '@libs/actions/Report/DeleteReport'; import {getLinkedTransactionID, getReportAction, getReportActionMessage, isCreatedTaskReportAction} from '@libs/ReportActionsUtils'; import {getOriginalReportID} from '@libs/ReportUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type * as OnyxTypes from '@src/types/onyx'; import type ReportAction from '@src/types/onyx/ReportAction'; -import {deleteReport} from './Report'; type IgnoreDirection = 'parent' | 'child'; diff --git a/tests/actions/IOUTest.ts b/tests/actions/IOUTest.ts index 55dfdaac81cf..0fcb7b1592bb 100644 --- a/tests/actions/IOUTest.ts +++ b/tests/actions/IOUTest.ts @@ -56,7 +56,8 @@ import {getSendInvoiceInformation} from '@libs/actions/IOU/SendInvoice'; import {completeSplitBill, splitBill, startSplitBill, updateSplitTransactionsFromSplitExpensesFlow} from '@libs/actions/IOU/Split'; import initOnyxDerivedValues from '@libs/actions/OnyxDerived'; import {createWorkspace, deleteWorkspace, generatePolicyID, setWorkspaceApprovalMode} from '@libs/actions/Policy/Policy'; -import {addComment, createNewReport, deleteReport, notifyNewAction, openReport} from '@libs/actions/Report'; +import {addComment, createNewReport, notifyNewAction, openReport} from '@libs/actions/Report'; +import deleteReport from '@libs/actions/Report/DeleteReport'; import {clearAllRelatedReportActionErrors} from '@libs/actions/ReportActions'; import {subscribeToUserEvents} from '@libs/actions/User'; import type {ApiCommand} from '@libs/API/types'; From e9e63333a79bec491333fe544c4a43fad7b6ee48 Mon Sep 17 00:00:00 2001 From: Bernhard Owen Josephus Date: Sat, 14 Feb 2026 13:48:14 +0800 Subject: [PATCH 002/435] lint --- src/libs/actions/Report/DeleteReport.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/libs/actions/Report/DeleteReport.ts b/src/libs/actions/Report/DeleteReport.ts index 60e55c2e0092..2a5024e35136 100644 --- a/src/libs/actions/Report/DeleteReport.ts +++ b/src/libs/actions/Report/DeleteReport.ts @@ -1,10 +1,10 @@ import Onyx from 'react-native-onyx'; -import type {OnyxCollection} from 'react-native-onyx'; +import type { OnyxCollection } from 'react-native-onyx'; import Log from '@libs/Log'; -import {getOriginalMessage, isMoneyRequestAction} from '@libs/ReportActionsUtils'; -import CONST from '@src/CONST'; +import { getOriginalMessage, isMoneyRequestAction } from '@libs/ReportActionsUtils'; +import type CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import type {Report, ReportAction, ReportActions} from '@src/types/onyx'; +import type { Report, ReportAction, ReportActions } from '@src/types/onyx'; // We use connectWithoutView because deleteReport doesn't affect the UI rendering // and this avoids unnecessary re-rendering because we recursively delete the report and its children From d092763c1535c373fa34a78389c2338c97909805 Mon Sep 17 00:00:00 2001 From: Bernhard Owen Josephus Date: Sat, 14 Feb 2026 13:48:48 +0800 Subject: [PATCH 003/435] lint --- src/libs/actions/ReportActions.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/libs/actions/ReportActions.ts b/src/libs/actions/ReportActions.ts index f856a0d40589..476a887daa52 100644 --- a/src/libs/actions/ReportActions.ts +++ b/src/libs/actions/ReportActions.ts @@ -1,8 +1,8 @@ -import type {OnyxCollection} from 'react-native-onyx'; +import type { OnyxCollection } from 'react-native-onyx'; import Onyx from 'react-native-onyx'; -import deleteReport from '@libs/actions/Report/DeleteReport'; -import {getLinkedTransactionID, getReportAction, getReportActionMessage, isCreatedTaskReportAction} from '@libs/ReportActionsUtils'; -import {getOriginalReportID} from '@libs/ReportUtils'; +import deleteReport from './Report/DeleteReport'; +import { getLinkedTransactionID, getReportAction, getReportActionMessage, isCreatedTaskReportAction } from '@libs/ReportActionsUtils'; +import { getOriginalReportID } from '@libs/ReportUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type * as OnyxTypes from '@src/types/onyx'; @@ -105,7 +105,7 @@ function clearAllRelatedReportActionErrors(reportID: string | undefined, reportA } } -export type {IgnoreDirection}; +export type { IgnoreDirection }; export { // eslint-disable-next-line import/prefer-default-export clearAllRelatedReportActionErrors, From 59e41f12a603fa479b4814adc72dc380ce2da4d9 Mon Sep 17 00:00:00 2001 From: Bernhard Owen Josephus Date: Sat, 14 Feb 2026 14:20:42 +0800 Subject: [PATCH 004/435] prettier --- src/libs/actions/Report/DeleteReport.ts | 6 +++--- src/libs/actions/ReportActions.ts | 10 +++++----- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/libs/actions/Report/DeleteReport.ts b/src/libs/actions/Report/DeleteReport.ts index 2a5024e35136..22b334189607 100644 --- a/src/libs/actions/Report/DeleteReport.ts +++ b/src/libs/actions/Report/DeleteReport.ts @@ -1,10 +1,10 @@ import Onyx from 'react-native-onyx'; -import type { OnyxCollection } from 'react-native-onyx'; +import type {OnyxCollection} from 'react-native-onyx'; import Log from '@libs/Log'; -import { getOriginalMessage, isMoneyRequestAction } from '@libs/ReportActionsUtils'; +import {getOriginalMessage, isMoneyRequestAction} from '@libs/ReportActionsUtils'; import type CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import type { Report, ReportAction, ReportActions } from '@src/types/onyx'; +import type {Report, ReportAction, ReportActions} from '@src/types/onyx'; // We use connectWithoutView because deleteReport doesn't affect the UI rendering // and this avoids unnecessary re-rendering because we recursively delete the report and its children diff --git a/src/libs/actions/ReportActions.ts b/src/libs/actions/ReportActions.ts index 476a887daa52..4b2d50272ba4 100644 --- a/src/libs/actions/ReportActions.ts +++ b/src/libs/actions/ReportActions.ts @@ -1,12 +1,12 @@ -import type { OnyxCollection } from 'react-native-onyx'; +import type {OnyxCollection} from 'react-native-onyx'; import Onyx from 'react-native-onyx'; -import deleteReport from './Report/DeleteReport'; -import { getLinkedTransactionID, getReportAction, getReportActionMessage, isCreatedTaskReportAction } from '@libs/ReportActionsUtils'; -import { getOriginalReportID } from '@libs/ReportUtils'; +import {getLinkedTransactionID, getReportAction, getReportActionMessage, isCreatedTaskReportAction} from '@libs/ReportActionsUtils'; +import {getOriginalReportID} from '@libs/ReportUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type * as OnyxTypes from '@src/types/onyx'; import type ReportAction from '@src/types/onyx/ReportAction'; +import deleteReport from './Report/DeleteReport'; type IgnoreDirection = 'parent' | 'child'; @@ -105,7 +105,7 @@ function clearAllRelatedReportActionErrors(reportID: string | undefined, reportA } } -export type { IgnoreDirection }; +export type {IgnoreDirection}; export { // eslint-disable-next-line import/prefer-default-export clearAllRelatedReportActionErrors, From e681c5e09bf38edba5496b9e25da7a558c4d6b82 Mon Sep 17 00:00:00 2001 From: Bernhard Owen Josephus Date: Sat, 14 Feb 2026 16:43:08 +0800 Subject: [PATCH 005/435] add test --- tests/unit/DeleteReportTest.ts | 114 +++++++++++++++++++++++++++++++++ 1 file changed, 114 insertions(+) create mode 100644 tests/unit/DeleteReportTest.ts diff --git a/tests/unit/DeleteReportTest.ts b/tests/unit/DeleteReportTest.ts new file mode 100644 index 000000000000..6a1633816d91 --- /dev/null +++ b/tests/unit/DeleteReportTest.ts @@ -0,0 +1,114 @@ +import Onyx from 'react-native-onyx'; +import deleteReport from '@libs/actions/Report/DeleteReport'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import {ReportAction} from '@src/types/onyx'; +import createRandomReportAction from '../utils/collections/reportActions'; +import {createRandomReport} from '../utils/collections/reports'; +import createRandomTransaction from '../utils/collections/transaction'; +import getOnyxValue from '../utils/getOnyxValue'; +import waitForBatchedUpdates from '../utils/waitForBatchedUpdates'; + +// Mock Log to avoid noise in tests +jest.mock('@libs/Log'); + +describe('actions/Report/DeleteReport', () => { + beforeAll(() => { + Onyx.init({ + keys: ONYXKEYS, + }); + }); + + beforeEach(() => { + return Onyx.clear(); + }); + + it('should delete a report, its actions, and linked transactions', async () => { + // Given a report with an IOU action and a linked transaction + const reportID = '1'; + const transactionID = '123'; + const report = createRandomReport(1, undefined); + const transaction = createRandomTransaction(Number(transactionID)); + const reportAction: ReportAction = { + ...createRandomReportAction(100), + actionName: CONST.REPORT.ACTIONS.TYPE.IOU, + previousMessage: [], + message: [{text: '', html: 'iou', type: 'COMMENT'}], + originalMessage: { + type: CONST.IOU.REPORT_ACTION_TYPE.CREATE, + IOUTransactionID: transaction.transactionID, + IOUDetails: { + amount: transaction.amount, + currency: transaction.currency, + comment: '', + }, + }, + }; + + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`, report); + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`, { + [reportAction.reportActionID]: reportAction, + }); + await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, transaction); + + // When we delete the report + deleteReport(reportID); + + await waitForBatchedUpdates(); + + // Then the report, its actions, and the linked transaction should be deleted + expect(await getOnyxValue(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`)).toBeUndefined(); + expect(await getOnyxValue(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`)).toBeUndefined(); + expect(await getOnyxValue(`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`)).toBeUndefined(); + }); + + it('should delete child reports recursively when shouldDeleteChildReports is true', async () => { + // Given a parent report with a child report + const parentReportID = '1'; + const childReportID = '2'; + const parentReport = createRandomReport(Number(parentReportID), undefined); + const childReport = createRandomReport(Number(childReportID), undefined); + const parentAction = { + ...createRandomReportAction(100), + childReportID, + }; + + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${parentReportID}`, parentReport); + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${childReportID}`, childReport); + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${parentReportID}`, { + [parentAction.reportActionID]: parentAction, + }); + + // When we delete the parent report with shouldDeleteChildReports set to true + deleteReport(parentReportID, true); + + await waitForBatchedUpdates(); + + // Then both the parent and child reports should be deleted + expect(await getOnyxValue(`${ONYXKEYS.COLLECTION.REPORT}${parentReportID}`)).toBeUndefined(); + expect(await getOnyxValue(`${ONYXKEYS.COLLECTION.REPORT}${childReportID}`)).toBeUndefined(); + }); + + it('should delete linked IOU reports recursively', async () => { + // Given a report with a linked IOU report + const reportID = '1'; + const iouReportID = '2'; + const report = { + ...createRandomReport(Number(reportID), undefined), + iouReportID, + }; + const iouReport = createRandomReport(Number(iouReportID), undefined); + + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`, report); + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${iouReportID}`, iouReport); + + // When we delete the report + deleteReport(reportID); + + await waitForBatchedUpdates(); + + // Then both reports should be deleted + expect(await getOnyxValue(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`)).toBeUndefined(); + expect(await getOnyxValue(`${ONYXKEYS.COLLECTION.REPORT}${iouReportID}`)).toBeUndefined(); + }); +}); From 37f777963ccf609a6175dcb5ec35f2c06fe6330f Mon Sep 17 00:00:00 2001 From: Bernhard Owen Josephus Date: Wed, 18 Feb 2026 11:54:57 +0800 Subject: [PATCH 006/435] lint --- tests/unit/DeleteReportTest.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/unit/DeleteReportTest.ts b/tests/unit/DeleteReportTest.ts index 6a1633816d91..adeeb4b275c8 100644 --- a/tests/unit/DeleteReportTest.ts +++ b/tests/unit/DeleteReportTest.ts @@ -2,7 +2,7 @@ import Onyx from 'react-native-onyx'; import deleteReport from '@libs/actions/Report/DeleteReport'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import {ReportAction} from '@src/types/onyx'; +import type {ReportAction} from '@src/types/onyx'; import createRandomReportAction from '../utils/collections/reportActions'; import {createRandomReport} from '../utils/collections/reports'; import createRandomTransaction from '../utils/collections/transaction'; From 5455d55d8db55cb85ad2a3ed8aa8a00ddb306b35 Mon Sep 17 00:00:00 2001 From: Brian Lee Date: Mon, 30 Mar 2026 09:16:38 -0700 Subject: [PATCH 007/435] Update Domain-Migration.md --- docs/articles/new-expensify/domains/Domain-Migration.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/articles/new-expensify/domains/Domain-Migration.md b/docs/articles/new-expensify/domains/Domain-Migration.md index 04f7247bc12d..7fd5426cdeb9 100644 --- a/docs/articles/new-expensify/domains/Domain-Migration.md +++ b/docs/articles/new-expensify/domains/Domain-Migration.md @@ -21,7 +21,7 @@ This is the recommended approach when possible. Concierge can typically perform an automatic Domain Migration if: -- The old domain or new domain is verified +- The old domain is verified - Email addresses match a 1:1 format, such as `name@olddomain.com` to `name@newdomain.com` - Members have not already created separate accounts under the new email address @@ -65,7 +65,7 @@ Yes. If done correctly, members keep their full expense history, reports, and ap ## Do I need to verify the new domain before migrating? -Yes. You must claim and verify the new domain before transferring domain control or updating member login emails. +No. It is actually preferred that you do not add or verify the new domain in Expensify. ## What happens to the Expensify Card during Domain Migration? From 1a2b4b64364a56c2c692ffc80591767b1638fde1 Mon Sep 17 00:00:00 2001 From: Brian Lee Date: Mon, 30 Mar 2026 09:23:50 -0700 Subject: [PATCH 008/435] Update Domain-Migration.md --- docs/articles/new-expensify/domains/Domain-Migration.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/articles/new-expensify/domains/Domain-Migration.md b/docs/articles/new-expensify/domains/Domain-Migration.md index 7fd5426cdeb9..7d06e6adbdcc 100644 --- a/docs/articles/new-expensify/domains/Domain-Migration.md +++ b/docs/articles/new-expensify/domains/Domain-Migration.md @@ -65,7 +65,8 @@ Yes. If done correctly, members keep their full expense history, reports, and ap ## Do I need to verify the new domain before migrating? -No. It is actually preferred that you do not add or verify the new domain in Expensify. +- If you are migrating your domain automatically with assistance from Concierge, you will not need to verify the new domain. It is preferred that you do not add or verify the new domain in Expensify. +- If you are migrating your domain manually, you will need to verify the new domain. ## What happens to the Expensify Card during Domain Migration? From 4cc504a4cddd8a52187525600595d236714ebffd Mon Sep 17 00:00:00 2001 From: Shawn Borton Date: Fri, 17 Apr 2026 12:29:29 +0200 Subject: [PATCH 009/435] Replace Priority Mode with Inbox Tab Filters Replace the hidden Focus/Priority mode toggle in Account > Preferences with visible tab filters at the top of the Inbox: All, Unread, Expenses, and Direct messages. This gives users immediate, discoverable control over which chats they see. - Add CONST.INBOX_TAB and ONYXKEYS.NVP_INBOX_TAB for tab state - Add filterReportsForInboxTab() post-filter in SidebarUtils - Add InboxTabSelector component using TabSelectorBase with small size - Add 'small' size variant to TabSelector components (28px height, 11px font) - Remove isInFocusMode from the entire report pipeline - Remove PriorityModeHandler, PriorityModeController, FocusModeNotification - Remove PriorityModePage and its route/navigation config - Remove updateChatPriorityMode API command - Update all test files Co-Authored-By: Claude Opus 4.6 (1M context) --- src/CONST/index.ts | 9 +- src/Expensify.tsx | 2 - src/ONYXKEYS.ts | 10 +- src/PriorityModeHandler.tsx | 30 ----- src/ROUTES.ts | 1 - src/SCREENS.ts | 1 - src/components/FocusModeNotification.tsx | 46 -------- .../LHNOptionsList/OptionRowLHN.tsx | 23 ++-- src/components/PriorityModeController.tsx | 110 ------------------ .../ScrollOffsetContextProvider.tsx | 10 +- src/components/TabSelector/TabLabel.tsx | 11 +- .../TabSelector/TabSelectorBase.tsx | 4 +- .../TabSelector/TabSelectorItem.tsx | 3 + src/components/TabSelector/types.ts | 10 +- src/hooks/useSidebarOrderedReports.tsx | 70 +++++++---- src/languages/de.ts | 23 +--- src/languages/en.ts | 23 +--- src/languages/es.ts | 24 +--- src/languages/fr.ts | 23 +--- src/languages/it.ts | 23 +--- src/languages/ja.ts | 23 +--- src/languages/nl.ts | 23 +--- src/languages/pl.ts | 23 +--- src/languages/pt-BR.ts | 23 +--- src/languages/zh-hans.ts | 22 +--- .../UpdateChatPriorityModeParams.ts | 9 -- src/libs/API/parameters/index.ts | 1 - src/libs/API/types.ts | 2 - src/libs/DebugUtils.ts | 3 - .../Navigation/AppNavigator/AuthScreens.tsx | 2 - .../ModalStackNavigators/index.tsx | 1 - .../RELATIONS/SETTINGS_TO_RHP.ts | 7 +- .../linkingConfig/RELATIONS/SIDEBAR_TO_RHP.ts | 2 +- src/libs/Navigation/linkingConfig/config.ts | 4 - src/libs/Navigation/types.ts | 1 - src/libs/OptionsListUtils/index.ts | 1 - src/libs/ReportUtils.ts | 17 +-- src/libs/SidebarUtils.ts | 59 +++++++--- src/libs/UnreadIndicatorUpdater/index.ts | 1 - src/libs/actions/App.ts | 1 - src/libs/actions/Delegate.ts | 1 - src/libs/actions/QueuedOnyxUpdates.ts | 1 - src/libs/actions/Session/index.ts | 1 - src/libs/actions/User.ts | 36 +----- src/pages/Debug/Report/DebugReportPage.tsx | 4 +- src/pages/inbox/sidebar/BaseSidebarScreen.tsx | 2 + src/pages/inbox/sidebar/InboxTabSelector.tsx | 35 ++++++ src/pages/inbox/sidebar/SidebarLinks.tsx | 11 +- src/pages/inbox/sidebar/SidebarLinksData.tsx | 6 - .../settings/Preferences/PreferencesPage.tsx | 10 -- .../settings/Preferences/PriorityModePage.tsx | 68 ----------- src/styles/index.ts | 15 +++ src/types/onyx/InboxTab.ts | 6 + src/types/onyx/PriorityMode.ts | 8 -- tests/actions/QueuedOnyxUpdatesTest.ts | 2 - tests/perf-test/SidebarLinks.perf-test.tsx | 4 +- tests/ui/LHNItemsPresence.tsx | 26 ----- tests/unit/ReportUtilsTest.ts | 71 ----------- tests/unit/SidebarFilterTest.ts | 12 -- tests/unit/SidebarOrderTest.ts | 17 --- tests/unit/SidebarTest.ts | 4 +- tests/unit/SidebarUtilsTest.ts | 14 +-- 62 files changed, 267 insertions(+), 768 deletions(-) delete mode 100644 src/PriorityModeHandler.tsx delete mode 100644 src/components/FocusModeNotification.tsx delete mode 100644 src/components/PriorityModeController.tsx delete mode 100644 src/libs/API/parameters/UpdateChatPriorityModeParams.ts create mode 100644 src/pages/inbox/sidebar/InboxTabSelector.tsx delete mode 100644 src/pages/settings/Preferences/PriorityModePage.tsx create mode 100644 src/types/onyx/InboxTab.ts delete mode 100644 src/types/onyx/PriorityMode.ts diff --git a/src/CONST/index.ts b/src/CONST/index.ts index b33731461863..3fbca9aa9280 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -2083,9 +2083,11 @@ const CONST = { MEMORY_THRESHOLD_IOS_WARNING_MB: 300, // > 300MB monitor closely }, }, - PRIORITY_MODE: { - GSD: 'gsd', - DEFAULT: 'default', + INBOX_TAB: { + ALL: 'all', + UNREADS: 'unreads', + EXPENSES: 'expenses', + DIRECT_MESSAGES: 'directMessages', }, THEME: { DEFAULT: 'system', @@ -9485,7 +9487,6 @@ const CONST = { ADDRESS: 'SettingsProfile-Address', }, SETTINGS_PREFERENCES: { - PRIORITY_MODE: 'SettingsPreferences-PriorityMode', LANGUAGE: 'SettingsPreferences-Language', PAYMENT_CURRENCY: 'SettingsPreferences-PaymentCurrency', THEME: 'SettingsPreferences-Theme', diff --git a/src/Expensify.tsx b/src/Expensify.tsx index 70f8a81666f6..902b9b101e27 100644 --- a/src/Expensify.tsx +++ b/src/Expensify.tsx @@ -33,7 +33,6 @@ import {startBootsplashMonitor} from './libs/telemetry/bootsplashTelemetry'; import {cleanupMemoryTrackingTelemetry, initializeMemoryTrackingTelemetry} from './libs/telemetry/TelemetrySynchronizer'; import Visibility from './libs/Visibility'; import ONYXKEYS from './ONYXKEYS'; -import PriorityModeHandler from './PriorityModeHandler'; import type {Route} from './ROUTES'; import {useSplashScreenActions, useSplashScreenState} from './SplashScreenStateContext'; @@ -273,7 +272,6 @@ function Expensify() { return ( <> {shouldInit && } - diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts index b3062bf03dc7..b7da30c7c155 100755 --- a/src/ONYXKEYS.ts +++ b/src/ONYXKEYS.ts @@ -159,8 +159,8 @@ const ONYXKEYS = { /** Contains the platforms for which the user muted the sounds */ NVP_MUTED_PLATFORMS: 'nvp_mutedPlatforms', - /** Contains the user preference for the LHN priority mode */ - NVP_PRIORITY_MODE: 'nvp_priorityMode', + /** Contains the user preference for the active inbox tab filter */ + NVP_INBOX_TAB: 'nvp_inboxTab', /** Contains the users's block expiration (if they have one) */ NVP_BLOCKED_FROM_CONCIERGE: 'nvp_private_blockedFromConcierge', @@ -212,9 +212,6 @@ const ONYXKEYS = { /** Whether the app is currently loading a translation */ RAM_ONLY_ARE_TRANSLATIONS_LOADING: 'areTranslationsLoading', - /** Whether the user has tried focus mode yet */ - NVP_TRY_FOCUS_MODE: 'nvp_tryFocusMode', - /** Whether the user has dismissed the hold educational interstitial */ NVP_DISMISSED_HOLD_USE_EXPLANATION: 'nvp_dismissedHoldUseExplanation', @@ -1356,7 +1353,7 @@ type OnyxValuesMapping = { [ONYXKEYS.BETAS]: OnyxTypes.Beta[]; [ONYXKEYS.BETA_CONFIGURATION]: OnyxTypes.BetaConfiguration; [ONYXKEYS.NVP_MUTED_PLATFORMS]: Partial>; - [ONYXKEYS.NVP_PRIORITY_MODE]: ValueOf; + [ONYXKEYS.NVP_INBOX_TAB]: ValueOf; [ONYXKEYS.NVP_BLOCKED_FROM_CONCIERGE]: OnyxTypes.BlockedFromConcierge; [ONYXKEYS.QUEUE_FLUSHED_DATA]: AnyOnyxUpdate[]; [ONYXKEYS.TRANSACTIONS_PENDING_3DS_REVIEW]: OnyxTypes.TransactionsPending3DSReview; @@ -1366,7 +1363,6 @@ type OnyxValuesMapping = { [ONYXKEYS.NVP_BLOCKED_FROM_CHAT]: string; [ONYXKEYS.NVP_PRIVATE_PUSH_NOTIFICATION_ID]: string; [ONYXKEYS.NVP_RECENT_ATTENDEES]: Attendee[]; - [ONYXKEYS.NVP_TRY_FOCUS_MODE]: boolean; [ONYXKEYS.NVP_DISMISSED_HOLD_USE_EXPLANATION]: boolean; [ONYXKEYS.NVP_DISMISSED_ASAP_SUBMIT_EXPLANATION]: boolean; [ONYXKEYS.NVP_EMPTY_REPORTS_CONFIRMATION_DISMISSED]: boolean; diff --git a/src/PriorityModeHandler.tsx b/src/PriorityModeHandler.tsx deleted file mode 100644 index 3c3326f24807..000000000000 --- a/src/PriorityModeHandler.tsx +++ /dev/null @@ -1,30 +0,0 @@ -import {useEffect} from 'react'; -import CONST from './CONST'; -import useOnyx from './hooks/useOnyx'; -import usePrevious from './hooks/usePrevious'; -import {openApp} from './libs/actions/App'; -import ONYXKEYS from './ONYXKEYS'; - -/** - * Component that does not render anything but isolates priority-mode–related Onyx subscriptions - * (NVP_PRIORITY_MODE, COLLECTION.REPORT_DRAFT_COMMENT) from the root Expensify component so that - * changes to these keys do not re-render the entire navigation tree. - */ -function PriorityModeHandler() { - const [priorityMode] = useOnyx(ONYXKEYS.NVP_PRIORITY_MODE); - const [allReportsWithDraftComments] = useOnyx(ONYXKEYS.COLLECTION.REPORT_DRAFT_COMMENT); - const prevPriorityMode = usePrevious(priorityMode); - - useEffect(() => { - if (!(prevPriorityMode === CONST.PRIORITY_MODE.GSD && priorityMode === CONST.PRIORITY_MODE.DEFAULT)) { - return; - } - // When a user switches their priority mode away from #focus/GSD we need to call openApp - // to fetch all their chats because #focus mode works with a subset of a user's chats. - openApp(false, allReportsWithDraftComments); - }, [priorityMode, allReportsWithDraftComments, prevPriorityMode]); - - return null; -} - -export default PriorityModeHandler; diff --git a/src/ROUTES.ts b/src/ROUTES.ts index 5477d2b21a3c..e30765e78f81 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -556,7 +556,6 @@ const ROUTES = { // eslint-disable-next-line no-restricted-syntax -- Legacy route generation getRoute: (backTo?: string) => getUrlWithBackToParam('settings/subscription/downgrade-blocked', backTo), }, - SETTINGS_PRIORITY_MODE: 'settings/preferences/priority-mode', SETTINGS_LANGUAGE: 'settings/preferences/language', SETTINGS_PAYMENT_CURRENCY: 'setting/preferences/payment-currency', SETTINGS_THEME: 'settings/preferences/theme', diff --git a/src/SCREENS.ts b/src/SCREENS.ts index 933edaedffee..8316e488c214 100644 --- a/src/SCREENS.ts +++ b/src/SCREENS.ts @@ -160,7 +160,6 @@ const SCREENS = { PREFERENCES: { ROOT: 'Settings_Preferences', - PRIORITY_MODE: 'Settings_Preferences_PriorityMode', LANGUAGE: 'Settings_Preferences_Language', THEME: 'Settings_Preferences_Theme', PAYMENT_CURRENCY: 'Settings_Payment_Currency', diff --git a/src/components/FocusModeNotification.tsx b/src/components/FocusModeNotification.tsx deleted file mode 100644 index d7f82bf7defc..000000000000 --- a/src/components/FocusModeNotification.tsx +++ /dev/null @@ -1,46 +0,0 @@ -import React from 'react'; -import {View} from 'react-native'; -import useEnvironment from '@hooks/useEnvironment'; -import {useMemoizedLazyIllustrations} from '@hooks/useLazyAsset'; -import useLocalize from '@hooks/useLocalize'; -import useStyleUtils from '@hooks/useStyleUtils'; -import useThemeStyles from '@hooks/useThemeStyles'; -import colors from '@styles/theme/colors'; -import ConfirmModal from './ConfirmModal'; -import RenderHTML from './RenderHTML'; - -type FocusModeNotificationProps = { - onClose: () => void; -}; - -function FocusModeNotification({onClose}: FocusModeNotificationProps) { - const styles = useThemeStyles(); - const StyleUtils = useStyleUtils(); - const illustrations = useMemoizedLazyIllustrations(['ThreeLeggedLaptopWoman']); - const {environmentURL} = useEnvironment(); - const {translate} = useLocalize(); - const priorityModePageUrl = `${environmentURL}/settings/preferences/priority-mode`; - - return ( - - - - } - success - isVisible - image={illustrations.ThreeLeggedLaptopWoman} - imageStyles={StyleUtils.getBackgroundColorStyle(colors.pink800)} - titleStyles={[styles.textHeadline, styles.mbn3]} - /> - ); -} - -export default FocusModeNotification; diff --git a/src/components/LHNOptionsList/OptionRowLHN.tsx b/src/components/LHNOptionsList/OptionRowLHN.tsx index 4f230b11cbf9..c1612c0a17d4 100644 --- a/src/components/LHNOptionsList/OptionRowLHN.tsx +++ b/src/components/LHNOptionsList/OptionRowLHN.tsx @@ -89,12 +89,13 @@ function OptionRowLHN({ const {translate} = useLocalize(); const [isContextMenuActive, setIsContextMenuActive] = useState(false); const currentUserPersonalDetails = useCurrentUserPersonalDetails(); - const isInFocusMode = viewMode === CONST.OPTION_MODE.COMPACT; - const sidebarInnerRowStyle = StyleSheet.flatten( - isInFocusMode - ? [styles.chatLinkRowPressable, styles.flexGrow1, styles.optionItemAvatarNameWrapper, styles.optionRowCompact, styles.justifyContentCenter] - : [styles.chatLinkRowPressable, styles.flexGrow1, styles.optionItemAvatarNameWrapper, styles.optionRow, styles.justifyContentCenter], - ); + const sidebarInnerRowStyle = StyleSheet.flatten([ + styles.chatLinkRowPressable, + styles.flexGrow1, + styles.optionItemAvatarNameWrapper, + styles.optionRow, + styles.justifyContentCenter, + ]); const alternateTextContainsCustomEmojiWithText = useMemo( () => containsCustomEmojiUtils(optionItem?.alternateText) && !containsOnlyCustomEmoji(optionItem?.alternateText), @@ -171,11 +172,9 @@ function OptionRowLHN({ const textStyle = isOptionFocused ? styles.sidebarLinkActiveText : styles.sidebarLinkText; const textUnreadStyle = shouldUseBoldText(optionItem) ? [textStyle, styles.sidebarLinkTextBold] : [textStyle]; const displayNameStyle = [styles.optionDisplayName, styles.optionDisplayNameCompact, styles.pre, textUnreadStyle, styles.flexShrink0, style]; - const alternateTextStyle = isInFocusMode - ? [textStyle, styles.textLabelSupporting, styles.optionAlternateTextCompact, styles.ml2, style] - : [textStyle, styles.optionAlternateText, styles.textLabelSupporting, style]; + const alternateTextStyle = [textStyle, styles.optionAlternateText, styles.textLabelSupporting, style]; - const contentContainerStyles = isInFocusMode ? [styles.flex1, styles.flexRow, styles.overflowHidden, StyleUtils.getCompactContentContainerStyles()] : [styles.flex1]; + const contentContainerStyles = [styles.flex1]; const hoveredBackgroundColor = !!styles.sidebarLinkHover && 'backgroundColor' in styles.sidebarLinkHover ? styles.sidebarLinkHover.backgroundColor : theme.sidebar; const focusedBackgroundColor = styles.sidebarLinkActive.backgroundColor; @@ -329,9 +328,9 @@ function OptionRowLHN({ >) => priorityMode === CONST.PRIORITY_MODE.GSD; - -/** - * This component is used to automatically switch a user into #focus mode when they exceed a certain number of reports. - * We do this primarily for performance reasons. Similar to the "Welcome action" we must wait for a number of things to - * happen when the user signs in or refreshes the page: - * - * - NVP that tracks whether they have already been switched over. We only do this once. - * - Priority mode NVP (that dictates the ordering/filtering logic of the LHN) - * - Reports to load (in ReconnectApp or OpenApp). As we check the count of the reports to determine whether the - * user is eligible to be automatically switched. - * - */ -export default function PriorityModeController() { - const {orderedReportIDs} = useSidebarOrderedReportsState(); - const lhnReportCount = orderedReportIDs.length; - const [isLoadingReportData] = useOnyx(ONYXKEYS.IS_LOADING_REPORT_DATA); - const [isInFocusMode, isInFocusModeMetadata] = useOnyx(ONYXKEYS.NVP_PRIORITY_MODE, {selector: isInFocusModeSelector}); - const [hasTriedFocusMode, hasTriedFocusModeMetadata] = useOnyx(ONYXKEYS.NVP_TRY_FOCUS_MODE); - const currentRouteName = useCurrentRouteName(); - const [shouldShowModal, setShouldShowModal] = useState(false); - const closeModal = useCallback(() => setShouldShowModal(false), []); - - // We set this when we have finally auto-switched the user of #focus mode to prevent duplication. - const hasSwitched = useRef(false); - - // Listen for state changes and trigger the #focus mode when appropriate - useEffect(() => { - // Wait for Onyx state to fully load - if ( - isLoadingReportData !== false || - isLoadingOnyxValue(isInFocusModeMetadata, hasTriedFocusModeMetadata) || - typeof isInFocusMode !== 'boolean' || - typeof hasTriedFocusMode !== 'boolean' - ) { - return; - } - - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing - if (hasSwitched.current || isInFocusMode || hasTriedFocusMode) { - return; - } - - if (lhnReportCount < CONST.REPORT.MAX_COUNT_BEFORE_FOCUS_UPDATE) { - Log.info('[PriorityModeController] Not switching user to focus mode as they do not have enough reports', false, {lhnReportCount}); - return; - } - - // We wait for the user to navigate back to the home screen before triggering this switch - const isNarrowLayout = getIsNarrowLayout(); - if ((isNarrowLayout && currentRouteName !== SCREENS.INBOX) || (!isNarrowLayout && currentRouteName !== SCREENS.REPORT)) { - Log.info("[PriorityModeController] Not switching user to focus mode as they aren't on the home screen", false, {lhnReportCount, currentRouteName}); - return; - } - - Log.info('[PriorityModeController] Switching user to focus mode', false, {lhnReportCount, hasTriedFocusMode, isInFocusMode, currentRouteName}); - updateChatPriorityMode(CONST.PRIORITY_MODE.GSD, true); - requestAnimationFrame(() => { - setShouldShowModal(true); - }); - hasSwitched.current = true; - }, [currentRouteName, hasTriedFocusMode, hasTriedFocusModeMetadata, isInFocusMode, isInFocusModeMetadata, isLoadingReportData, lhnReportCount]); - - useEffect(() => { - if (!shouldShowModal) { - return; - } - const isNavigatingToPriorityModePage = currentRouteName === SCREENS.SETTINGS.PREFERENCES.PRIORITY_MODE; - - // Hide focus modal when settings button is pressed from the prompt. - if (isNavigatingToPriorityModePage) { - setShouldShowModal(false); - } - }, [currentRouteName, shouldShowModal]); - - return shouldShowModal ? : null; -} - -/** - * A funky but reliable way to subscribe to screen changes. - */ -function useCurrentRouteName() { - const navigation = useNavigation(); - const [currentRouteName, setCurrentRouteName] = useState(''); - - useEffect(() => { - const unsubscribe = navigation.addListener('state', () => { - setCurrentRouteName(navigationRef.getCurrentRoute()?.name); - }); - return () => unsubscribe(); - }, [navigation]); - - return currentRouteName; -} diff --git a/src/components/ScrollOffsetContextProvider.tsx b/src/components/ScrollOffsetContextProvider.tsx index f051497606d4..2a59f6ee533a 100644 --- a/src/components/ScrollOffsetContextProvider.tsx +++ b/src/components/ScrollOffsetContextProvider.tsx @@ -60,22 +60,22 @@ function getKey(route: PlatformStackRouteProp | NavigationPartial } function ScrollOffsetContextProvider({children}: ScrollOffsetContextProviderProps) { - const [priorityMode] = useOnyx(ONYXKEYS.NVP_PRIORITY_MODE); + const [inboxTab] = useOnyx(ONYXKEYS.NVP_INBOX_TAB); const scrollOffsetsRef = useRef>({}); - const previousPriorityMode = usePrevious(priorityMode); + const previousInboxTab = usePrevious(inboxTab); useEffect(() => { - if (previousPriorityMode === null || previousPriorityMode === priorityMode) { + if (previousInboxTab === null || previousInboxTab === inboxTab) { return; } - // If the priority mode changes, we need to clear the scroll offsets for the home and search screens because it affects the size of the elements and scroll positions wouldn't be correct. + // If the inbox tab changes, we need to clear the scroll offsets for the home and search screens because it affects the size of the elements and scroll positions wouldn't be correct. for (const key of Object.keys(scrollOffsetsRef.current)) { if (key.includes(SCREENS.INBOX) || key.includes(SCREENS.SEARCH.ROOT)) { delete scrollOffsetsRef.current[key]; } } - }, [priorityMode, previousPriorityMode]); + }, [inboxTab, previousInboxTab]); const saveScrollOffset: ScrollOffsetContextValue['saveScrollOffset'] = useCallback((route, scrollOffset) => { scrollOffsetsRef.current[getKey(route)] = scrollOffset; diff --git a/src/components/TabSelector/TabLabel.tsx b/src/components/TabSelector/TabLabel.tsx index e47490b89f38..c00d939cdb44 100644 --- a/src/components/TabSelector/TabLabel.tsx +++ b/src/components/TabSelector/TabLabel.tsx @@ -5,6 +5,7 @@ import type {StyleProp, TextStyle} from 'react-native'; import Text from '@components/Text'; import useThemeStyles from '@hooks/useThemeStyles'; import variables from '@styles/variables'; +import type {TabSelectorSize} from './types'; type TabLabelProps = { /** Title of the tab */ @@ -21,16 +22,20 @@ type TabLabelProps = { /** Text style */ textStyle?: StyleProp; + + /** Size variant */ + size?: TabSelectorSize; }; -function TabLabel({title = '', activeOpacity = 0, inactiveOpacity = 1, hasIcon = false, textStyle}: TabLabelProps) { +function TabLabel({title = '', activeOpacity = 0, inactiveOpacity = 1, hasIcon = false, textStyle, size}: TabLabelProps) { const styles = useThemeStyles(); + const smallTextStyle = size === 'small' ? styles.tabTextSmall : undefined; return ( {title} @@ -38,7 +43,7 @@ function TabLabel({title = '', activeOpacity = 0, inactiveOpacity = 1, hasIcon = {title} diff --git a/src/components/TabSelector/TabSelectorBase.tsx b/src/components/TabSelector/TabSelectorBase.tsx index 0c48ea1b091a..ff544715247e 100644 --- a/src/components/TabSelector/TabSelectorBase.tsx +++ b/src/components/TabSelector/TabSelectorBase.tsx @@ -27,6 +27,7 @@ function TabSelectorBase({ position, shouldShowLabelWhenInactive = true, equalWidth = false, + size, shouldShowProductTrainingTooltip = false, renderProductTrainingTooltip, }: TabSelectorBaseProps) { @@ -64,7 +65,7 @@ function TabSelectorBase({ }} ref={containerRef} style={styles.scrollableTabSelector} - contentContainerStyle={styles.tabSelectorContentContainer} + contentContainerStyle={[styles.tabSelectorContentContainer, size === 'small' && styles.tabSelectorContentContainerSmall]} horizontal showsHorizontalScrollIndicator={false} keyboardShouldPersistTaps="handled" @@ -123,6 +124,7 @@ function TabSelectorBase({ shouldShowProductTrainingTooltip={shouldShowProductTrainingTooltip} renderProductTrainingTooltip={renderProductTrainingTooltip} equalWidth={equalWidth} + size={size} badgeText={tab.badgeText} pendingAction={tab.pendingAction} isDisabled={tab.isDisabled} diff --git a/src/components/TabSelector/TabSelectorItem.tsx b/src/components/TabSelector/TabSelectorItem.tsx index fb5ada5f9d94..47c825b2f2ff 100644 --- a/src/components/TabSelector/TabSelectorItem.tsx +++ b/src/components/TabSelector/TabSelectorItem.tsx @@ -34,6 +34,7 @@ function TabSelectorItem({ shouldShowProductTrainingTooltip = false, renderProductTrainingTooltip, equalWidth = false, + size, badgeText, isDisabled = false, pendingAction, @@ -58,6 +59,7 @@ function TabSelectorItem({ accessibilityRole={CONST.ROLE.TAB} style={[ styles.tabSelectorButton, + size === 'small' && styles.tabSelectorButtonSmall, styles.tabBackground(isHovered, isActive, isDisabled, backgroundColor), styles.userSelectNone, isOfflineWithPendingAction ? styles.offlineFeedbackPending : undefined, @@ -89,6 +91,7 @@ function TabSelectorItem({ activeOpacity={styles.tabOpacity(isDisabled, isHovered, isActive, activeOpacity, inactiveOpacity).opacity} inactiveOpacity={styles.tabOpacity(isDisabled, isHovered, isActive, inactiveOpacity, activeOpacity).opacity} hasIcon={!!icon} + size={size} /> )} {!!badgeText && ( diff --git a/src/components/TabSelector/types.ts b/src/components/TabSelector/types.ts index 4d5ac355b75c..20e0d3e0861d 100644 --- a/src/components/TabSelector/types.ts +++ b/src/components/TabSelector/types.ts @@ -52,6 +52,8 @@ type TabSelectorBaseItem = WithSentryLabel & { pendingAction?: PendingAction; }; +type TabSelectorSize = 'default' | 'small'; + type TabSelectorBaseProps = { /** Tabs to render. */ tabs: TabSelectorBaseItem[]; @@ -77,6 +79,9 @@ type TabSelectorBaseProps = { /** Whether tabs should have equal width. */ equalWidth?: boolean; + /** Size variant for the tabs. 'small' uses a compact 28px height. */ + size?: TabSelectorSize; + /** Determines whether the product training tooltip should be displayed to the user. */ shouldShowProductTrainingTooltip?: boolean; @@ -121,6 +126,9 @@ type TabSelectorItemProps = WithSentryLabel & { /** Whether tabs should have equal width */ equalWidth?: boolean; + /** Size variant for the tabs. */ + size?: TabSelectorSize; + /** Determines whether the product training tooltip should be displayed to the user. */ shouldShowProductTrainingTooltip?: boolean; @@ -182,4 +190,4 @@ type BackgroundColor = Animated.AnimatedInterpolation | string; type Opacity = 1 | 0 | Animated.AnimatedInterpolation; -export type {TabSelectorProps, BackgroundColor, GetBackgroundColorConfig, Opacity, GetOpacityConfig, TabSelectorBaseProps, TabSelectorBaseItem, TabSelectorItemProps}; +export type {TabSelectorProps, BackgroundColor, GetBackgroundColorConfig, Opacity, GetOpacityConfig, TabSelectorBaseProps, TabSelectorBaseItem, TabSelectorItemProps, TabSelectorSize}; diff --git a/src/hooks/useSidebarOrderedReports.tsx b/src/hooks/useSidebarOrderedReports.tsx index 8ae7ad1a86bf..bd2ea03621c7 100644 --- a/src/hooks/useSidebarOrderedReports.tsx +++ b/src/hooks/useSidebarOrderedReports.tsx @@ -1,6 +1,8 @@ import {deepEqual} from 'fast-equals'; import React, {createContext, useCallback, useContext, useEffect, useMemo, useRef, useState} from 'react'; import type {OnyxEntry} from 'react-native-onyx'; +import type {ValueOf} from 'type-fest'; +import {setInboxTab} from '@libs/actions/User'; import Log from '@libs/Log'; import {getTransactionThreadReportID} from '@libs/MergeTransactionUtils'; import {isOneTransactionReport} from '@libs/ReportUtils'; @@ -33,10 +35,12 @@ type SidebarOrderedReportsStateContextValue = { orderedReportIDs: string[]; currentReportID: string | undefined; chatTabBrickRoad: BrickRoad; + activeTab: ValueOf; }; type SidebarOrderedReportsActionsContextValue = { clearLHNCache: () => void; + setActiveTab: (tab: ValueOf) => void; }; type ReportsToDisplayInLHN = Record; @@ -46,10 +50,12 @@ const SidebarOrderedReportsStateContext = createContext({ clearLHNCache: () => {}, + setActiveTab: () => {}, }); const policyMapper = (policy: OnyxEntry): PartialPolicyForSidebar => @@ -73,7 +79,8 @@ function SidebarOrderedReportsContextProvider({ currentReportIDForTests, }: SidebarOrderedReportsContextProviderProps) { const {localeCompare} = useLocalize(); - const [priorityMode = CONST.PRIORITY_MODE.DEFAULT] = useOnyx(ONYXKEYS.NVP_PRIORITY_MODE); + const [inboxTab = CONST.INBOX_TAB.ALL] = useOnyx(ONYXKEYS.NVP_INBOX_TAB); + const activeTab = inboxTab ?? CONST.INBOX_TAB.ALL; const [chatReports, {sourceValue: reportUpdates}] = useOnyx(ONYXKEYS.COLLECTION.REPORT); const [policies, {sourceValue: policiesUpdates}] = useMappedPolicies(policyMapper); const [transactions, {sourceValue: transactionsUpdates}] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION); @@ -94,7 +101,6 @@ function SidebarOrderedReportsContextProvider({ const [clearCacheDummyCounter, setClearCacheDummyCounter] = useState(0); const prevBetas = usePrevious(betas); - const prevPriorityMode = usePrevious(priorityMode); const perfRef = useRef<{hookDuration: number}>({ hookDuration: 0, @@ -107,7 +113,7 @@ function SidebarOrderedReportsContextProvider({ const getUpdatedReports = useCallback(() => { const reportsToUpdate = new Set(); - if (betas !== prevBetas || priorityMode !== prevPriorityMode) { + if (betas !== prevBetas) { for (const key of Object.keys(chatReports ?? {})) { reportsToUpdate.add(key); } @@ -177,9 +183,7 @@ function SidebarOrderedReportsContextProvider({ chatReports, transactions, betas, - priorityMode, prevBetas, - prevPriorityMode, prevDerivedCurrentReportID, derivedCurrentReportID, ]); @@ -200,7 +204,6 @@ function SidebarOrderedReportsContextProvider({ reports: chatReports, updatedReportsKeys: effectiveUpdatedReports, currentReportId: derivedCurrentReportID, - isInFocusMode: priorityMode === CONST.PRIORITY_MODE.GSD, betas, transactionViolations, reportNameValuePairs, @@ -214,7 +217,6 @@ function SidebarOrderedReportsContextProvider({ derivedCurrentReportID, chatReports, betas, - priorityMode, reportsDrafts, transactionViolations, transactions, @@ -226,7 +228,7 @@ function SidebarOrderedReportsContextProvider({ return reportsToDisplay; // Rule disabled intentionally — triggering a re-render on currentReportsToDisplay would cause an infinite loop // eslint-disable-next-line react-hooks/exhaustive-deps - }, [getUpdatedReports, chatReports, derivedCurrentReportID, priorityMode, betas, transactionViolations, reportNameValuePairs, reportAttributes, reportsDrafts, clearCacheDummyCounter]); + }, [getUpdatedReports, chatReports, derivedCurrentReportID, betas, transactionViolations, reportNameValuePairs, reportAttributes, reportsDrafts, clearCacheDummyCounter]); // Derive a stable boolean map indicating which reports have drafts. const hasDraftByReportIDRef = useRef>({}); @@ -253,16 +255,23 @@ function SidebarOrderedReportsContextProvider({ setCurrentReportsToDisplay(reportsToDisplayInLHN); }, [reportsToDisplayInLHN]); + const useAlphabeticalSort = activeTab === CONST.INBOX_TAB.UNREADS; + const getOrderedReportIDs = useCallback( - () => SidebarUtils.sortReportsToDisplayInLHN(reportsToDisplayInLHN, priorityMode, localeCompare, hasDraftByReportID, reportNameValuePairs, reportAttributes), + () => SidebarUtils.sortReportsToDisplayInLHN(reportsToDisplayInLHN, useAlphabeticalSort, localeCompare, hasDraftByReportID, reportNameValuePairs, reportAttributes), // Rule disabled intentionally - reports should be sorted only when the reportsToDisplayInLHN changes // eslint-disable-next-line react-hooks/exhaustive-deps - [reportsToDisplayInLHN, localeCompare, hasDraftByReportID, reportAttributes], + [reportsToDisplayInLHN, useAlphabeticalSort, localeCompare, hasDraftByReportID, reportAttributes], ); const orderedReportIDs = useMemo(() => getOrderedReportIDs(), [getOrderedReportIDs]); - // Get the actual reports based on the ordered IDs + const filteredReportIDs = useMemo( + () => SidebarUtils.filterReportsForInboxTab(orderedReportIDs, reportsToDisplayInLHN, activeTab, hasDraftByReportID, reportNameValuePairs), + [orderedReportIDs, reportsToDisplayInLHN, activeTab, hasDraftByReportID, reportNameValuePairs], + ); + + // Get the actual reports based on the filtered IDs const getOrderedReports = useCallback( (reportIDs: string[]): OnyxTypes.Report[] => { if (!chatReports) { @@ -273,7 +282,7 @@ function SidebarOrderedReportsContextProvider({ [chatReports], ); - const orderedReports = useMemo(() => getOrderedReports(orderedReportIDs), [getOrderedReports, orderedReportIDs]); + const orderedReports = useMemo(() => getOrderedReports(filteredReportIDs), [getOrderedReports, filteredReportIDs]); const clearLHNCache = useCallback(() => { Log.info('[useSidebarOrderedReports] Clearing sidebar cache manually via debug modal'); @@ -281,6 +290,10 @@ function SidebarOrderedReportsContextProvider({ setClearCacheDummyCounter((current) => current + 1); }, []); + const setActiveTab = useCallback((tab: ValueOf) => { + setInboxTab(tab); + }, []); + const stateValue: SidebarOrderedReportsStateContextValue = useMemo(() => { // We need to make sure the current report is in the list of reports, but we do not want // to have to re-generate the list every time the currentReportID changes. To do that @@ -293,33 +306,49 @@ function SidebarOrderedReportsContextProvider({ // any expense, a new LHN item is added in the list and is visible on web. But on mobile, we // just navigate to the screen with expense details, so there seems no point to execute this logic on mobile. if ( - (!shouldUseNarrowLayout || orderedReportIDs.length === 0) && + (!shouldUseNarrowLayout || filteredReportIDs.length === 0) && derivedCurrentReportID && derivedCurrentReportID !== '-1' && - orderedReportIDs.indexOf(derivedCurrentReportID) === -1 + filteredReportIDs.indexOf(derivedCurrentReportID) === -1 ) { const updatedReportIDs = getOrderedReportIDs(); - const updatedReports = getOrderedReports(updatedReportIDs); + const updatedFilteredIDs = SidebarUtils.filterReportsForInboxTab(updatedReportIDs, reportsToDisplayInLHN, activeTab, hasDraftByReportID, reportNameValuePairs); + const updatedReports = getOrderedReports(updatedFilteredIDs); return { orderedReports: updatedReports, - orderedReportIDs: updatedReportIDs, + orderedReportIDs: updatedFilteredIDs, currentReportID: derivedCurrentReportID, chatTabBrickRoad: getChatTabBrickRoad(updatedReportIDs, reportAttributes), + activeTab, }; } return { orderedReports, - orderedReportIDs, + orderedReportIDs: filteredReportIDs, currentReportID: derivedCurrentReportID, chatTabBrickRoad: getChatTabBrickRoad(orderedReportIDs, reportAttributes), + activeTab, }; - }, [getOrderedReportIDs, orderedReportIDs, derivedCurrentReportID, shouldUseNarrowLayout, getOrderedReports, orderedReports, reportAttributes]); + }, [ + getOrderedReportIDs, + orderedReportIDs, + filteredReportIDs, + derivedCurrentReportID, + shouldUseNarrowLayout, + getOrderedReports, + orderedReports, + reportAttributes, + activeTab, + reportsToDisplayInLHN, + hasDraftByReportID, + reportNameValuePairs, + ]); - const actionsValue: SidebarOrderedReportsActionsContextValue = useMemo(() => ({clearLHNCache}), [clearLHNCache]); + const actionsValue: SidebarOrderedReportsActionsContextValue = useMemo(() => ({clearLHNCache, setActiveTab}), [clearLHNCache, setActiveTab]); const currentDeps = { - priorityMode, + activeTab, chatReports, policies, transactions, @@ -334,7 +363,6 @@ function SidebarOrderedReportsContextProvider({ derivedCurrentReportID, prevDerivedCurrentReportID, prevBetas, - prevPriorityMode, reportsToDisplayInLHN, orderedReportIDs, orderedReports, diff --git a/src/languages/de.ts b/src/languages/de.ts index e22f119c149c..9c9f75001ff2 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -2786,19 +2786,11 @@ ${amount} für ${merchant} – ${date}`, receiveRelevantFeatureUpdatesAndExpensifyNews: 'Erhalten Sie relevante Funktionsupdates und Expensify-Neuigkeiten', muteAllSounds: 'Alle Expensify-Sounds stummschalten', }, - priorityModePage: { - priorityMode: 'Prioritätsmodus', - explainerText: 'Wähle, ob du dich nur auf ungelesene und angeheftete Chats #fokussieren möchtest oder alles anzeigen willst, wobei die neuesten und angehefteten Chats oben stehen.', - priorityModes: { - default: { - label: 'Neueste', - description: 'Alle Chats nach Neuestem sortiert anzeigen', - }, - gsd: { - label: '#fokus', - description: 'Nur ungelesene alphabetisch sortiert anzeigen', - }, - }, + inboxTabs: { + all: 'All', + unreads: 'Unread', + expenses: 'Expenses', + directMessages: 'Direct messages', }, reportDetailsPage: { inWorkspace: (policyName: string) => `in ${policyName}`, @@ -3344,11 +3336,6 @@ ${amount} für ${merchant} – ${date}`, year: 'Jahr', selectYear: 'Bitte ein Jahr auswählen', }, - focusModeUpdateModal: { - title: 'Willkommen im #Fokusmodus!', - prompt: (priorityModePageUrl: string) => - `Behalte den Überblick, indem du nur ungelesene Chats oder Chats siehst, die deine Aufmerksamkeit benötigen. Keine Sorge, du kannst das jederzeit in den Einstellungen ändern.`, - }, notFound: { chatYouLookingForCannotBeFound: 'Der Chat, den du suchst, kann nicht gefunden werden.', getMeOutOfHere: 'Hol mich hier raus', diff --git a/src/languages/en.ts b/src/languages/en.ts index fb20d5d83d9c..12480bbb55c4 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -2840,19 +2840,11 @@ const translations = { receiveRelevantFeatureUpdatesAndExpensifyNews: 'Receive relevant feature updates and Expensify news', muteAllSounds: 'Mute all sounds from Expensify', }, - priorityModePage: { - priorityMode: 'Priority mode', - explainerText: 'Choose whether to #focus on unread and pinned chats only, or show everything with the most recent and pinned chats at the top.', - priorityModes: { - default: { - label: 'Most recent', - description: 'Show all chats sorted by most recent', - }, - gsd: { - label: '#focus', - description: 'Only show unread sorted alphabetically', - }, - }, + inboxTabs: { + all: 'All', + unreads: 'Unread', + expenses: 'Expenses', + directMessages: 'Direct messages', }, reportDetailsPage: { inWorkspace: (policyName: string) => `in ${policyName}`, @@ -3410,11 +3402,6 @@ const translations = { month: 'Month', selectMonth: 'Please select a month', }, - focusModeUpdateModal: { - title: 'Welcome to #focus mode!', - prompt: (priorityModePageUrl: string) => - `Stay on top of things by only seeing unread chats or chats that need your attention. Don’t worry, you can change this at any point in settings.`, - }, notFound: { chatYouLookingForCannotBeFound: 'The chat you are looking for cannot be found.', getMeOutOfHere: 'Get me out of here', diff --git a/src/languages/es.ts b/src/languages/es.ts index af44621bc5c6..d9243ca2d933 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -2657,20 +2657,11 @@ ${amount} para ${merchant} - ${date}`, receiveRelevantFeatureUpdatesAndExpensifyNews: 'Recibir noticias sobre Expensify y actualizaciones del producto', muteAllSounds: 'Silenciar todos los sonidos de Expensify', }, - priorityModePage: { - priorityMode: 'Modo prioridad', - explainerText: - 'Elige #concentración si deseas enfocarte sólo en los chats no leídos y en los anclados, o mostrarlo todo con los chats más recientes y los anclados en la parte superior.', - priorityModes: { - default: { - label: 'Más recientes', - description: 'Mostrar todos los chats ordenados desde el más reciente', - }, - gsd: { - label: '#concentración', - description: 'Mostrar sólo los no leídos ordenados alfabéticamente', - }, - }, + inboxTabs: { + all: 'All', + unreads: 'Unread', + expenses: 'Expenses', + directMessages: 'Direct messages', }, reportDetailsPage: { inWorkspace: (policyName) => `en ${policyName}`, @@ -3219,11 +3210,6 @@ ${amount} para ${merchant} - ${date}`, month: 'Mes', selectMonth: 'Por favor, selecciona un mes', }, - focusModeUpdateModal: { - title: '¡Bienvenido al modo #concentración!', - prompt: (priorityModePageUrl) => - `Mantente al tanto de todo viendo sólo los chats no leídos o los que necesitan tu atención. No te preocupes, puedes cambiar el ajuste en cualquier momento desde la configuración.`, - }, notFound: { chatYouLookingForCannotBeFound: 'El chat que estás buscando no se pudo encontrar.', getMeOutOfHere: 'Sácame de aquí', diff --git a/src/languages/fr.ts b/src/languages/fr.ts index dd2cf4363012..57e3862724a6 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -2792,19 +2792,11 @@ ${amount} pour ${merchant} - ${date}`, receiveRelevantFeatureUpdatesAndExpensifyNews: 'Recevoir des mises à jour de fonctionnalités pertinentes et des actualités Expensify', muteAllSounds: 'Couper tous les sons d’Expensify', }, - priorityModePage: { - priorityMode: 'Mode priorité', - explainerText: 'Choisissez de #vous concentrer uniquement sur les discussions non lues et épinglées, ou d’afficher tout avec les discussions les plus récentes et épinglées en haut.', - priorityModes: { - default: { - label: 'Le plus récent', - description: 'Afficher toutes les discussions triées par les plus récentes', - }, - gsd: { - label: '#focus', - description: 'Afficher uniquement les non lus triés par ordre alphabétique', - }, - }, + inboxTabs: { + all: 'All', + unreads: 'Unread', + expenses: 'Expenses', + directMessages: 'Direct messages', }, reportDetailsPage: { inWorkspace: (policyName: string) => `dans ${policyName}`, @@ -3353,11 +3345,6 @@ ${amount} pour ${merchant} - ${date}`, year: 'Année', selectYear: 'Veuillez sélectionner une année', }, - focusModeUpdateModal: { - title: 'Bienvenue en mode #focus !', - prompt: (priorityModePageUrl: string) => - `Gardez le contrôle en affichant uniquement les discussions non lues ou celles qui nécessitent votre attention. Ne vous inquiétez pas, vous pouvez modifier ce paramètre à tout moment dans les paramètres.`, - }, notFound: { chatYouLookingForCannotBeFound: 'La discussion que vous recherchez est introuvable.', getMeOutOfHere: 'Faites-moi sortir d’ici', diff --git a/src/languages/it.ts b/src/languages/it.ts index f177ee5d7b4f..eea77cc79424 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -2781,19 +2781,11 @@ ${amount} per ${merchant} - ${date}`, receiveRelevantFeatureUpdatesAndExpensifyNews: 'Ricevi aggiornamenti rilevanti sulle funzionalità e notizie su Expensify', muteAllSounds: 'Disattiva tutti i suoni da Expensify', }, - priorityModePage: { - priorityMode: 'Modalità prioritaria', - explainerText: 'Scegli se #concentrarti solo sulle chat non lette e fissate, oppure mostrare tutto con le chat più recenti e fissate in alto.', - priorityModes: { - default: { - label: 'Più recenti', - description: 'Mostra tutte le chat ordinate dalla più recente', - }, - gsd: { - label: '#focus', - description: 'Mostra solo i non letti in ordine alfabetico', - }, - }, + inboxTabs: { + all: 'All', + unreads: 'Unread', + expenses: 'Expenses', + directMessages: 'Direct messages', }, reportDetailsPage: { inWorkspace: (policyName: string) => `in ${policyName}`, @@ -3336,11 +3328,6 @@ ${amount} per ${merchant} - ${date}`, year: 'Anno', selectYear: 'Seleziona un anno', }, - focusModeUpdateModal: { - title: 'Benvenuto/a nella modalità #focus!', - prompt: (priorityModePageUrl: string) => - `Resta sempre aggiornato vedendo solo le chat non lette o quelle che richiedono la tua attenzione. Non preoccuparti, puoi modificare questa impostazione in qualsiasi momento nelle impostazioni.`, - }, notFound: { chatYouLookingForCannotBeFound: 'La chat che stai cercando non può essere trovata.', getMeOutOfHere: 'Fammi uscire di qui', diff --git a/src/languages/ja.ts b/src/languages/ja.ts index 5fd9507b6714..455bf7a8850a 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -2754,19 +2754,11 @@ ${date} の ${merchant} への ${amount}`, receiveRelevantFeatureUpdatesAndExpensifyNews: '関連する機能のアップデートやExpensifyのニュースを受け取る', muteAllSounds: 'Expensify のすべてのサウンドをミュートする', }, - priorityModePage: { - priorityMode: '優先モード', - explainerText: '未読とピン留めされたチャットのみを#focusに表示するか、すべてのチャットを表示して、最新とピン留めされたチャットを上部に表示するかを選択してください。', - priorityModes: { - default: { - label: '最新', - description: '最新順ですべてのチャットを表示', - }, - gsd: { - label: '#focus', - description: '未読のみをアルファベット順で表示', - }, - }, + inboxTabs: { + all: 'All', + unreads: 'Unread', + expenses: 'Expenses', + directMessages: 'Direct messages', }, reportDetailsPage: { inWorkspace: (policyName: string) => `${policyName} 内`, @@ -3308,11 +3300,6 @@ ${integrationName === CONST.ONBOARDING_ACCOUNTING_MAPPING.other ? 'あなたの' year: '年', selectYear: '年を選択してください', }, - focusModeUpdateModal: { - title: '#focusモードへようこそ!', - prompt: (priorityModePageUrl: string) => - `未読のチャットや対応が必要なチャットだけを表示して、常に状況を把握しましょう。いつでも設定から変更できます。`, - }, notFound: { chatYouLookingForCannotBeFound: 'お探しのチャットが見つかりません。', getMeOutOfHere: 'ここから出して', diff --git a/src/languages/nl.ts b/src/languages/nl.ts index 4447363ef30f..4038df5299d4 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -2779,19 +2779,11 @@ ${amount} voor ${merchant} - ${date}`, receiveRelevantFeatureUpdatesAndExpensifyNews: 'Ontvang relevante functiewijzigingen en Expensify-nieuws', muteAllSounds: 'Alle geluiden van Expensify dempen', }, - priorityModePage: { - priorityMode: 'Prioriteitsmodus', - explainerText: 'Kies of je je wilt #focussen op alleen ongelezen en vastgezette chats, of alles wilt weergeven met de meest recente en vastgezette chats bovenaan.', - priorityModes: { - default: { - label: 'Meest recent', - description: 'Toon alle chats gesorteerd op meest recent', - }, - gsd: { - label: '#focus', - description: 'Toon alleen ongelezen, alfabetisch gesorteerd', - }, - }, + inboxTabs: { + all: 'All', + unreads: 'Unread', + expenses: 'Expenses', + directMessages: 'Direct messages', }, reportDetailsPage: { inWorkspace: (policyName: string) => `in ${policyName}`, @@ -3333,11 +3325,6 @@ ${amount} voor ${merchant} - ${date}`, year: 'Jaar', selectYear: 'Selecteer een jaar', }, - focusModeUpdateModal: { - title: 'Welkom bij de #focus-modus!', - prompt: (priorityModePageUrl: string) => - `Houd het overzicht door alleen ongelezen chats of chats die je aandacht nodig hebben te zien. Geen zorgen, je kunt dit op elk moment wijzigen in de instellingen.`, - }, notFound: { chatYouLookingForCannotBeFound: 'De chat die je zoekt, kan niet worden gevonden.', getMeOutOfHere: 'Haal me hier weg', diff --git a/src/languages/pl.ts b/src/languages/pl.ts index fd9e3b730a1d..496974e494b8 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -2772,19 +2772,11 @@ ${amount} dla ${merchant} - ${date}`, receiveRelevantFeatureUpdatesAndExpensifyNews: 'Otrzymuj istotne aktualizacje funkcji i wiadomości od Expensify', muteAllSounds: 'Wycisz wszystkie dźwięki z Expensify', }, - priorityModePage: { - priorityMode: 'Tryb priorytetowy', - explainerText: 'Wybierz, czy #skupić się tylko na nieprzeczytanych i przypiętych czatach, czy wyświetlać wszystko, z najnowszymi i przypiętymi czatami na górze.', - priorityModes: { - default: { - label: 'Najnowsze', - description: 'Pokaż wszystkie czaty posortowane od najnowszych', - }, - gsd: { - label: '#skupienie', - description: 'Pokaż tylko nieprzeczytane posortowane alfabetycznie', - }, - }, + inboxTabs: { + all: 'All', + unreads: 'Unread', + expenses: 'Expenses', + directMessages: 'Direct messages', }, reportDetailsPage: { inWorkspace: (policyName: string) => `w ${policyName}`, @@ -3324,11 +3316,6 @@ ${amount} dla ${merchant} - ${date}`, year: 'Rok', selectYear: 'Wybierz rok', }, - focusModeUpdateModal: { - title: 'Witamy w trybie #focus!', - prompt: (priorityModePageUrl: string) => - `Miej wszystko pod kontrolą, wyświetlając tylko nieprzeczytane czaty lub czaty wymagające Twojej uwagi. Nie martw się, możesz to zmienić w dowolnym momencie w ustawieniach.`, - }, notFound: { chatYouLookingForCannotBeFound: 'Nie można znaleźć czatu, którego szukasz.', getMeOutOfHere: 'Wyprowadź mnie stąd', diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index 52479b6be5a0..9cecddddfb48 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -2772,19 +2772,11 @@ ${amount} para ${merchant} - ${date}`, receiveRelevantFeatureUpdatesAndExpensifyNews: 'Receba atualizações relevantes de recursos e novidades da Expensify', muteAllSounds: 'Silenciar todos os sons do Expensify', }, - priorityModePage: { - priorityMode: 'Modo prioridade', - explainerText: 'Escolha se deseja #focar apenas em chats não lidos e fixados ou mostrar tudo, com os chats mais recentes e fixados no topo.', - priorityModes: { - default: { - label: 'Mais recente', - description: 'Mostrar todos os chats ordenados por mais recentes', - }, - gsd: { - label: '#foco', - description: 'Mostrar apenas não lidas em ordem alfabética', - }, - }, + inboxTabs: { + all: 'All', + unreads: 'Unread', + expenses: 'Expenses', + directMessages: 'Direct messages', }, reportDetailsPage: { inWorkspace: (policyName: string) => `em ${policyName}`, @@ -3325,11 +3317,6 @@ ${amount} para ${merchant} - ${date}`, year: 'Ano', selectYear: 'Selecione um ano', }, - focusModeUpdateModal: { - title: 'Bem-vindo ao modo #focus!', - prompt: (priorityModePageUrl: string) => - `Mantenha tudo sob controle vendo apenas os chats não lidos ou que precisam da sua atenção. Não se preocupe, você pode alterar isso a qualquer momento em configurações.`, - }, notFound: { chatYouLookingForCannotBeFound: 'O chat que você está procurando não foi encontrado.', getMeOutOfHere: 'Me tire daqui', diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index 40049ed183d1..aade94e81823 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -2704,19 +2704,11 @@ ${amount},商户:${merchant} - 日期:${date}`, receiveRelevantFeatureUpdatesAndExpensifyNews: '接收相关功能更新和 Expensify 新闻', muteAllSounds: '静音所有来自 Expensify 的声音', }, - priorityModePage: { - priorityMode: '优先模式', - explainerText: '选择仅#focus未读和置顶聊天,或显示所有聊天,并将最新和置顶聊天排在顶部。', - priorityModes: { - default: { - label: '最新', - description: '按最新排序显示所有聊天', - }, - gsd: { - label: '#focus', - description: '仅按字母顺序显示未读', - }, - }, + inboxTabs: { + all: 'All', + unreads: 'Unread', + expenses: 'Expenses', + directMessages: 'Direct messages', }, reportDetailsPage: { inWorkspace: (policyName: string) => `在 ${policyName} 中`, @@ -3255,10 +3247,6 @@ ${amount},商户:${merchant} - 日期:${date}`, year: '年份', selectYear: '请选择年份', }, - focusModeUpdateModal: { - title: '欢迎进入 #focus 模式!', - prompt: (priorityModePageUrl: string) => `通过仅查看未读聊天或需要你关注的聊天来随时掌握进展。别担心,你可以随时在设置中更改此项。`, - }, notFound: { chatYouLookingForCannotBeFound: '找不到您要查找的聊天。', getMeOutOfHere: '带我离开这里', diff --git a/src/libs/API/parameters/UpdateChatPriorityModeParams.ts b/src/libs/API/parameters/UpdateChatPriorityModeParams.ts deleted file mode 100644 index 8bbb7bf6943c..000000000000 --- a/src/libs/API/parameters/UpdateChatPriorityModeParams.ts +++ /dev/null @@ -1,9 +0,0 @@ -import type {ValueOf} from 'type-fest'; -import type CONST from '@src/CONST'; - -type UpdateChatPriorityModeParams = { - value: ValueOf; - automatic: boolean; -}; - -export default UpdateChatPriorityModeParams; diff --git a/src/libs/API/parameters/index.ts b/src/libs/API/parameters/index.ts index 7c7c54e55efd..96ef0f32a456 100644 --- a/src/libs/API/parameters/index.ts +++ b/src/libs/API/parameters/index.ts @@ -91,7 +91,6 @@ export type {default as SignInWithShortLivedAuthTokenParams} from './SignInWithS export type {default as SignInWithSupportAuthTokenParams} from './SignInWithSupportAuthTokenParams'; export type {default as UnlinkLoginParams} from './UnlinkLoginParams'; export type {default as UpdateAutomaticTimezoneParams} from './UpdateAutomaticTimezoneParams'; -export type {default as UpdateChatPriorityModeParams} from './UpdateChatPriorityModeParams'; export type {default as DuplicateWorkspaceParams} from './DuplicateWorkspaceParams'; export type {default as UpdateDateOfBirthParams} from './UpdateDateOfBirthParams'; export type {default as UpdateDisplayNameParams} from './UpdateDisplayNameParams'; diff --git a/src/libs/API/types.ts b/src/libs/API/types.ts index 15959c65d415..05255a2dae10 100644 --- a/src/libs/API/types.ts +++ b/src/libs/API/types.ts @@ -90,7 +90,6 @@ const WRITE_COMMANDS = { VALIDATE_LOGIN: 'ValidateLogin', VALIDATE_SECONDARY_LOGIN: 'ValidateSecondaryLogin', UPDATE_PREFERRED_EMOJI_SKIN_TONE: 'UpdatePreferredEmojiSkinTone', - UPDATE_CHAT_PRIORITY_MODE: 'UpdateChatPriorityMode', TOGGLE_PLATFORM_MUTE: 'TogglePlatformMute', SET_CONTACT_METHOD_AS_DEFAULT: 'SetContactMethodAsDefault', UPDATE_THEME: 'UpdateTheme', @@ -642,7 +641,6 @@ type WriteCommandParameters = { [WRITE_COMMANDS.VALIDATE_LOGIN]: Parameters.ValidateLoginParams; [WRITE_COMMANDS.VALIDATE_SECONDARY_LOGIN]: Parameters.ValidateSecondaryLoginParams; [WRITE_COMMANDS.UPDATE_PREFERRED_EMOJI_SKIN_TONE]: Parameters.UpdatePreferredEmojiSkinToneParams; - [WRITE_COMMANDS.UPDATE_CHAT_PRIORITY_MODE]: Parameters.UpdateChatPriorityModeParams; [WRITE_COMMANDS.SET_CONTACT_METHOD_AS_DEFAULT]: Parameters.SetContactMethodAsDefaultParams; [WRITE_COMMANDS.TOGGLE_PLATFORM_MUTE]: Parameters.TogglePlatformMuteParams; [WRITE_COMMANDS.UPDATE_THEME]: Parameters.UpdateThemeParams; diff --git a/src/libs/DebugUtils.ts b/src/libs/DebugUtils.ts index 31a0a1ee5ef5..882b2dc74af7 100644 --- a/src/libs/DebugUtils.ts +++ b/src/libs/DebugUtils.ts @@ -1405,7 +1405,6 @@ function getReasonForShowingRowInLHN({ doesReportHaveViolations, hasRBR = false, isReportArchived, - isInFocusMode = false, betas = undefined, draftComment, }: { @@ -1414,7 +1413,6 @@ function getReasonForShowingRowInLHN({ doesReportHaveViolations: boolean; hasRBR?: boolean; isReportArchived: boolean | undefined; - isInFocusMode?: boolean; betas?: OnyxEntry; draftComment: string | undefined; }): TranslationPaths | null { @@ -1427,7 +1425,6 @@ function getReasonForShowingRowInLHN({ chatReport, // We can't pass report.reportID because it will cause reason to always be isFocused currentReportId: '-1', - isInFocusMode, betas, excludeEmptyChats: true, doesReportHaveViolations, diff --git a/src/libs/Navigation/AppNavigator/AuthScreens.tsx b/src/libs/Navigation/AppNavigator/AuthScreens.tsx index 9b381ec63314..63ac14d40790 100644 --- a/src/libs/Navigation/AppNavigator/AuthScreens.tsx +++ b/src/libs/Navigation/AppNavigator/AuthScreens.tsx @@ -12,7 +12,6 @@ import KYCWallContextProvider from '@components/KYCWall/KYCWallContext'; import LockedAccountModalProvider from '@components/LockedAccountModalProvider'; import OpenAppFailureModal from '@components/OpenAppFailureModal'; import OptionsListContextProvider from '@components/OptionListContextProvider'; -import PriorityModeController from '@components/PriorityModeController'; import {ProductTrainingContextProvider} from '@components/ProductTrainingContext'; import {SearchContextProvider} from '@components/Search/SearchContext'; import {SearchRouterContextProvider} from '@components/Search/SearchRouter/SearchRouterContext'; @@ -410,7 +409,6 @@ function AuthScreens() { - diff --git a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx index 36e3c84a261f..3774bbf6a49f 100644 --- a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx +++ b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx @@ -407,7 +407,6 @@ const SettingsModalStackNavigator = createModalStackNavigator require('../../../../pages/settings/Profile/Contacts/NewContactMethodConfirmMagicCodePage').default, [SCREENS.SETTINGS.PROFILE.CONTACT_METHOD_VERIFY_ACCOUNT]: () => require('../../../../pages/settings/Profile/Contacts/VerifyAccountPage').default, - [SCREENS.SETTINGS.PREFERENCES.PRIORITY_MODE]: () => require('../../../../pages/settings/Preferences/PriorityModePage').default, [SCREENS.WORKSPACE.ACCOUNTING.ROOT]: () => require('../../../../pages/workspace/accounting/PolicyAccountingPage').default, [SCREENS.SETTINGS.PREFERENCES.LANGUAGE]: () => require('../../../../pages/settings/Preferences/LanguagePage').default, [SCREENS.SETTINGS.PREFERENCES.THEME]: () => require('../../../../pages/settings/Preferences/ThemePage').default, diff --git a/src/libs/Navigation/linkingConfig/RELATIONS/SETTINGS_TO_RHP.ts b/src/libs/Navigation/linkingConfig/RELATIONS/SETTINGS_TO_RHP.ts index 7e59623ed3ac..7ae9ae4d67dc 100755 --- a/src/libs/Navigation/linkingConfig/RELATIONS/SETTINGS_TO_RHP.ts +++ b/src/libs/Navigation/linkingConfig/RELATIONS/SETTINGS_TO_RHP.ts @@ -24,12 +24,7 @@ const SETTINGS_TO_RHP: Partial> = { - [SCREENS.SETTINGS.ROOT]: [SCREENS.SETTINGS.SHARE_CODE, SCREENS.SETTINGS.PROFILE.STATUS, SCREENS.SETTINGS.PREFERENCES.PRIORITY_MODE], + [SCREENS.SETTINGS.ROOT]: [SCREENS.SETTINGS.SHARE_CODE, SCREENS.SETTINGS.PROFILE.STATUS], }; export default SIDEBAR_TO_RHP; diff --git a/src/libs/Navigation/linkingConfig/config.ts b/src/libs/Navigation/linkingConfig/config.ts index 3057fbe0034d..401ac54bbe29 100644 --- a/src/libs/Navigation/linkingConfig/config.ts +++ b/src/libs/Navigation/linkingConfig/config.ts @@ -149,10 +149,6 @@ const config: LinkingOptions['config'] = { screens: { [SCREENS.RIGHT_MODAL.SETTINGS]: { screens: { - [SCREENS.SETTINGS.PREFERENCES.PRIORITY_MODE]: { - path: ROUTES.SETTINGS_PRIORITY_MODE, - exact: true, - }, [SCREENS.SETTINGS.PREFERENCES.LANGUAGE]: { path: ROUTES.SETTINGS_LANGUAGE, exact: true, diff --git a/src/libs/Navigation/types.ts b/src/libs/Navigation/types.ts index 214d096a99d1..c662d36fbdd2 100644 --- a/src/libs/Navigation/types.ts +++ b/src/libs/Navigation/types.ts @@ -125,7 +125,6 @@ type SettingsNavigatorParamList = { backTo?: Routes; forwardTo?: Routes; }; - [SCREENS.SETTINGS.PREFERENCES.PRIORITY_MODE]: undefined; [SCREENS.SETTINGS.PREFERENCES.PAYMENT_CURRENCY]: undefined; [SCREENS.SETTINGS.PREFERENCES.LANGUAGE]: undefined; [SCREENS.SETTINGS.PREFERENCES.THEME]: undefined; diff --git a/src/libs/OptionsListUtils/index.ts b/src/libs/OptionsListUtils/index.ts index 6b372a0b54bf..a6a0619ebdc6 100644 --- a/src/libs/OptionsListUtils/index.ts +++ b/src/libs/OptionsListUtils/index.ts @@ -2157,7 +2157,6 @@ function isValidReport(option: SearchOption, policy: OnyxEntry, currentReportId: topmostReportId, betas, doesReportHaveViolations, - isInFocusMode: false, excludeEmptyChats: false, includeSelfDM, login: option.login, diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index 3c04a6ebab12..d238e7f22b62 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -9508,7 +9508,6 @@ type ShouldReportBeInOptionListParams = { report: OnyxEntry; chatReport: OnyxEntry; currentReportId: string | undefined; - isInFocusMode: boolean; betas: OnyxEntry; excludeEmptyChats: boolean; doesReportHaveViolations: boolean; @@ -9525,7 +9524,6 @@ function reasonForReportToBeInOptionList({ report, chatReport, currentReportId, - isInFocusMode, betas, excludeEmptyChats, doesReportHaveViolations, @@ -9536,8 +9534,6 @@ function reasonForReportToBeInOptionList({ isReportArchived, requiresAttention, }: ShouldReportBeInOptionListParams): ValueOf | null { - const isInDefaultMode = !isInFocusMode; - // 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. @@ -9656,17 +9652,8 @@ function reasonForReportToBeInOptionList({ 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) { - const oneTransactionThreadReportID = getOneTransactionThreadReportID(report, chatReport, currentReportActions); - const oneTransactionThreadReport = deprecatedAllReports?.[`${ONYXKEYS.COLLECTION.REPORT}${oneTransactionThreadReportID}`]; - return isUnread(report, oneTransactionThreadReport, isReportArchived) && 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 && isArchivedNonExpenseReport(report, isReportArchived)) { + // Archived reports should always be shown so users can still access and search for them. + if (isArchivedNonExpenseReport(report, isReportArchived)) { return CONST.REPORT_IN_LHN_REASONS.IS_ARCHIVED; } diff --git a/src/libs/SidebarUtils.ts b/src/libs/SidebarUtils.ts index f85b14ff3336..623caf8ad8d8 100644 --- a/src/libs/SidebarUtils.ts +++ b/src/libs/SidebarUtils.ts @@ -21,7 +21,6 @@ import type Beta from '@src/types/onyx/Beta'; import type {ReportAttributes} from '@src/types/onyx/DerivedValues'; import type {Errors} from '@src/types/onyx/OnyxCommon'; import type Policy from '@src/types/onyx/Policy'; -import type PriorityMode from '@src/types/onyx/PriorityMode'; import type Report from '@src/types/onyx/Report'; import type ReportAction from '@src/types/onyx/ReportAction'; import {formatPhoneNumber as formatPhoneNumberPhoneUtils} from './LocalePhoneNumber'; @@ -169,6 +168,7 @@ import { isChatThread, isConciergeChatReport, isDeprecatedGroupDM, + isDM, isDomainRoom, isExpenseReport, isExpenseRequest, @@ -177,6 +177,7 @@ import { isInvoiceReport, isInvoiceRoom, isIOUOwnedByCurrentUser, + isIOUReport, isJoinRequestInAdminRoom, isMoneyRequestReport, isOneOnOneChat, @@ -264,7 +265,6 @@ function shouldDisplayReportInLHN( report: Report, reports: OnyxCollection, currentReportId: string | undefined, - isInFocusMode: boolean, betas: OnyxEntry, transactionViolations: OnyxCollection, draftComment: OnyxEntry, @@ -320,7 +320,6 @@ function shouldDisplayReportInLHN( report, chatReport, currentReportId, - isInFocusMode, betas, excludeEmptyChats: true, doesReportHaveViolations, @@ -337,14 +336,12 @@ function getReportsToDisplayInLHN( currentReportId: string | undefined, reports: OnyxCollection, betas: OnyxEntry, - priorityMode: OnyxEntry, draftComments: OnyxCollection, transactionViolations: OnyxCollection, transactions: OnyxCollection, reportNameValuePairs?: OnyxCollection, reportAttributes?: ReportAttributesDerivedValue['reports'], ) { - const isInFocusMode = priorityMode === CONST.PRIORITY_MODE.GSD; const allReportsDictValues = reports ?? {}; const reportsToDisplay: ReportsToDisplayInLHN = {}; @@ -359,7 +356,6 @@ function getReportsToDisplayInLHN( report, reports, currentReportId, - isInFocusMode, betas, transactionViolations, reportDraftComment, @@ -383,7 +379,6 @@ type UpdateReportsToDisplayInLHNProps = { reports: OnyxCollection; updatedReportsKeys: string[]; currentReportId: string | undefined; - isInFocusMode: boolean; betas: OnyxEntry; transactionViolations: OnyxCollection; reportNameValuePairs?: OnyxCollection; @@ -397,7 +392,6 @@ function updateReportsToDisplayInLHN({ reports, updatedReportsKeys, currentReportId, - isInFocusMode, betas, transactionViolations, reportNameValuePairs, @@ -431,7 +425,6 @@ function updateReportsToDisplayInLHN({ report, reports, currentReportId, - isInFocusMode, betas, transactionViolations, reportDraftComment, @@ -626,24 +619,23 @@ function combineReportCategories( */ function sortReportsToDisplayInLHN( reportsToDisplay: ReportsToDisplayInLHN, - priorityMode: OnyxEntry, + useAlphabeticalSort: boolean, localeCompare: LocaleContextProps['localeCompare'], reportsDrafts: Record | undefined, reportNameValuePairs: OnyxCollection | undefined, reportAttributes: ReportAttributesDerivedValue['reports'] | undefined, ): string[] { - const isInFocusMode = priorityMode === CONST.PRIORITY_MODE.GSD; - const isInDefaultMode = !isInFocusMode; + const isInDefaultMode = !useAlphabeticalSort; // The LHN is split into five distinct groups, and each group is sorted a little differently. The groups will ALWAYS be in this order: // 1. Pinned/GBR - Always sorted by reportDisplayName // 2. Error reports - Always sorted by reportDisplayName // 3. Drafts - Always sorted by reportDisplayName // 4. Non-archived reports and settled IOUs - // - Sorted by lastVisibleActionCreated in default (most recent) view mode - // - Sorted by reportDisplayName in GSD (focus) view mode + // - Sorted by lastVisibleActionCreated when not using alphabetical sort + // - Sorted by reportDisplayName when using alphabetical sort // 5. Archived reports - // - Sorted by lastVisibleActionCreated in default (most recent) view mode - // - Sorted by reportDisplayName in GSD (focus) view mode + // - Sorted by lastVisibleActionCreated when not using alphabetical sort + // - Sorted by reportDisplayName when using alphabetical sort // Step 1: Categorize reports const categories = categorizeReportsForLHN(reportsToDisplay, reportsDrafts, reportAttributes, reportNameValuePairs); @@ -1416,6 +1408,40 @@ function getRoomWelcomeMessage( return welcomeMessage; } +function filterReportsForInboxTab( + reportIDs: string[], + reportsToDisplay: ReportsToDisplayInLHN, + activeTab: ValueOf, + reportsDrafts?: Record, + reportNameValuePairs?: OnyxCollection, +): string[] { + if (activeTab === CONST.INBOX_TAB.ALL) { + return reportIDs; + } + + return reportIDs.filter((reportID) => { + const report = reportsToDisplay[`${ONYXKEYS.COLLECTION.REPORT}${reportID}`]; + if (!report) { + return false; + } + + switch (activeTab) { + case CONST.INBOX_TAB.UNREADS: { + const isReportArchived = isArchivedReport(reportNameValuePairs?.[`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${reportID}`]); + const isReportUnread = isUnread(report, undefined, isReportArchived) && getReportNotificationPreference(report) !== CONST.REPORT.NOTIFICATION_PREFERENCE.MUTE; + const hasDraft = !!reportsDrafts?.[reportID]; + return isReportUnread || !!report.isPinned || !!report.requiresAttention || !!report.hasErrorsOtherThanFailedReceipt || hasDraft; + } + case CONST.INBOX_TAB.EXPENSES: + return isExpenseReport(report) || isIOUReport(report) || isInvoiceReport(report) || isPolicyExpenseChat(report); + case CONST.INBOX_TAB.DIRECT_MESSAGES: + return isDM(report) || isSelfDM(report) || isGroupChatUtil(report); + default: + return true; + } + }); +} + // Exported for unit testing only. Do not use directly in production code. export { categorizeReportsForLHN as _categorizeReportsForLHN, @@ -1427,6 +1453,7 @@ export { export default { getOptionData, sortReportsToDisplayInLHN, + filterReportsForInboxTab, getWelcomeMessage, getReasonAndReportActionThatHasRedBrickRoad, getReportsToDisplayInLHN, diff --git a/src/libs/UnreadIndicatorUpdater/index.ts b/src/libs/UnreadIndicatorUpdater/index.ts index 272620e70b58..b3066332fa2f 100644 --- a/src/libs/UnreadIndicatorUpdater/index.ts +++ b/src/libs/UnreadIndicatorUpdater/index.ts @@ -58,7 +58,6 @@ function getUnreadReportsForUnreadIndicator(reports: OnyxCollection, cur currentReportId: currentReportID, betas: [], doesReportHaveViolations: false, - isInFocusMode: false, excludeEmptyChats: false, isReportArchived, draftComment, diff --git a/src/libs/actions/App.ts b/src/libs/actions/App.ts index 82a90babd548..2cc411ee3d2f 100644 --- a/src/libs/actions/App.ts +++ b/src/libs/actions/App.ts @@ -128,7 +128,6 @@ const KEYS_TO_PRESERVE: OnyxKey[] = [ ONYXKEYS.NETWORK, ONYXKEYS.SESSION, ONYXKEYS.SHOULD_SHOW_COMPOSE_INPUT, - ONYXKEYS.NVP_TRY_FOCUS_MODE, ONYXKEYS.PREFERRED_THEME, ONYXKEYS.NVP_PREFERRED_LOCALE, ONYXKEYS.CREDENTIALS, diff --git a/src/libs/actions/Delegate.ts b/src/libs/actions/Delegate.ts index a31097d7e3a7..bea82b9297c4 100644 --- a/src/libs/actions/Delegate.ts +++ b/src/libs/actions/Delegate.ts @@ -21,7 +21,6 @@ import updateSessionAuthTokens from './Session/updateSessionAuthTokens'; import updateSessionUser from './Session/updateSessionUser'; const KEYS_TO_PRESERVE_DELEGATE_ACCESS = [ - ONYXKEYS.NVP_TRY_FOCUS_MODE, ONYXKEYS.PREFERRED_THEME, ONYXKEYS.NVP_PREFERRED_LOCALE, ONYXKEYS.RAM_ONLY_ARE_TRANSLATIONS_LOADING, diff --git a/src/libs/actions/QueuedOnyxUpdates.ts b/src/libs/actions/QueuedOnyxUpdates.ts index 62ce86816243..32ff59170cc7 100644 --- a/src/libs/actions/QueuedOnyxUpdates.ts +++ b/src/libs/actions/QueuedOnyxUpdates.ts @@ -35,7 +35,6 @@ function flushQueue(): Promise { if (!currentAccountID && !CONFIG.IS_TEST_ENV) { const preservedKeys = new Set([ ONYXKEYS.NVP_TRY_NEW_DOT, - ONYXKEYS.NVP_TRY_FOCUS_MODE, ONYXKEYS.PREFERRED_THEME, ONYXKEYS.NVP_PREFERRED_LOCALE, ONYXKEYS.RAM_ONLY_ARE_TRANSLATIONS_LOADING, diff --git a/src/libs/actions/Session/index.ts b/src/libs/actions/Session/index.ts index 8047f230359c..c0f55b9817ab 100644 --- a/src/libs/actions/Session/index.ts +++ b/src/libs/actions/Session/index.ts @@ -296,7 +296,6 @@ function isExpiredSession(sessionCreationDate: number): boolean { } const KEYS_TO_PRESERVE_SUPPORTAL = [ - ONYXKEYS.NVP_TRY_FOCUS_MODE, ONYXKEYS.PREFERRED_THEME, ONYXKEYS.NVP_PREFERRED_LOCALE, ONYXKEYS.RAM_ONLY_ARE_TRANSLATIONS_LOADING, diff --git a/src/libs/actions/User.ts b/src/libs/actions/User.ts index b1fce73ddde6..138d02d821d5 100644 --- a/src/libs/actions/User.ts +++ b/src/libs/actions/User.ts @@ -18,7 +18,6 @@ import type { SetContactMethodAsDefaultParams, SetNameValuePairParams, TogglePlatformMuteParams, - UpdateChatPriorityModeParams, UpdateNewsletterSubscriptionParams, UpdatePreferredEmojiSkinToneParams, UpdateStatusParams, @@ -950,37 +949,8 @@ function updatePreferredSkinTone(skinTone: number) { API.write(WRITE_COMMANDS.UPDATE_PREFERRED_EMOJI_SKIN_TONE, parameters, {optimisticData}); } -/** - * Sync user chat priority mode with Onyx and Server - * @param mode - * @param [automatic] if we changed the mode automatically - */ -function updateChatPriorityMode(mode: ValueOf, automatic = false) { - const autoSwitchedToFocusMode = mode === CONST.PRIORITY_MODE.GSD && automatic; - const optimisticData: Array> = [ - { - onyxMethod: Onyx.METHOD.MERGE, - key: ONYXKEYS.NVP_PRIORITY_MODE, - value: mode, - }, - ]; - - optimisticData.push({ - onyxMethod: Onyx.METHOD.MERGE, - key: ONYXKEYS.NVP_TRY_FOCUS_MODE, - value: true, - }); - - const parameters: UpdateChatPriorityModeParams = { - value: mode, - automatic, - }; - - API.write(WRITE_COMMANDS.UPDATE_CHAT_PRIORITY_MODE, parameters, {optimisticData}); - - if (!autoSwitchedToFocusMode) { - Navigation.goBack(); - } +function setInboxTab(tab: ValueOf) { + Onyx.merge(ONYXKEYS.NVP_INBOX_TAB, tab); } function setShouldUseStagingServer(shouldUseStagingServer: boolean) { @@ -1907,12 +1877,12 @@ export { isBlockedFromConcierge, subscribeToUserEvents, updatePreferredSkinTone, + setInboxTab, setShouldUseStagingServer, togglePlatformMute, joinScreenShare, clearScreenShareRequest, generateStatementPDF, - updateChatPriorityMode, setContactMethodAsDefault, updateTheme, resetContactMethodValidateCodeSentState, diff --git a/src/pages/Debug/Report/DebugReportPage.tsx b/src/pages/Debug/Report/DebugReportPage.tsx index 1510b45cfddc..de203b55cc32 100644 --- a/src/pages/Debug/Report/DebugReportPage.tsx +++ b/src/pages/Debug/Report/DebugReportPage.tsx @@ -69,7 +69,6 @@ function DebugReportPage({ [reportAttributesSelector], ); const [draftComment] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_DRAFT_COMMENT}${reportID}`); - const [priorityMode] = useOnyx(ONYXKEYS.NVP_PRIORITY_MODE); const [betas] = useOnyx(ONYXKEYS.BETAS); const [conciergeReportID] = useOnyx(ONYXKEYS.CONCIERGE_REPORT_ID); const [introSelected] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED); @@ -106,7 +105,6 @@ function DebugReportPage({ doesReportHaveViolations: shouldDisplayViolations, hasRBR, isReportArchived, - isInFocusMode: priorityMode === CONST.PRIORITY_MODE.GSD, draftComment, }); @@ -153,7 +151,7 @@ function DebugReportPage({ : undefined, }, ]; - }, [report, transactionViolations, isReportArchived, chatReport, reportActions, transactions, reportAttributes?.reportErrors, betas, priorityMode, draftComment, translate]); + }, [report, transactionViolations, isReportArchived, chatReport, reportActions, transactions, reportAttributes?.reportErrors, betas, draftComment, translate]); const icons = useMemoizedLazyExpensifyIcons(['Eye']); diff --git a/src/pages/inbox/sidebar/BaseSidebarScreen.tsx b/src/pages/inbox/sidebar/BaseSidebarScreen.tsx index f20aa890543e..fb4a748e7460 100644 --- a/src/pages/inbox/sidebar/BaseSidebarScreen.tsx +++ b/src/pages/inbox/sidebar/BaseSidebarScreen.tsx @@ -14,6 +14,7 @@ import useThemeStyles from '@hooks/useThemeStyles'; import {isMobile} from '@libs/Browser'; import type {SkeletonSpanReasonAttributes} from '@libs/telemetry/useSkeletonSpan'; import ONYXKEYS from '@src/ONYXKEYS'; +import InboxTabSelector from './InboxTabSelector'; import SidebarLinksData from './SidebarLinksData'; // Once the app finishes loading for the first time, we never show the skeleton again @@ -59,6 +60,7 @@ function BaseSidebarScreen() { shouldDisplaySearch={shouldUseNarrowLayout} shouldDisplayHelpButton={shouldUseNarrowLayout} /> + {!shouldShowSkeleton && } {shouldShowSkeleton ? ( + + + ); +} + +InboxTabSelector.displayName = 'InboxTabSelector'; + +export default InboxTabSelector; diff --git a/src/pages/inbox/sidebar/SidebarLinks.tsx b/src/pages/inbox/sidebar/SidebarLinks.tsx index 6793096bc0fe..38d7d019de41 100644 --- a/src/pages/inbox/sidebar/SidebarLinks.tsx +++ b/src/pages/inbox/sidebar/SidebarLinks.tsx @@ -1,8 +1,6 @@ import React, {memo, useCallback, useEffect, useMemo} from 'react'; import {StyleSheet, View} from 'react-native'; -import type {OnyxEntry} from 'react-native-onyx'; import type {EdgeInsets} from 'react-native-safe-area-context'; -import type {ValueOf} from 'type-fest'; import LHNEmptyState from '@components/LHNOptionsList/LHNEmptyState'; import LHNOptionsList from '@components/LHNOptionsList/LHNOptionsList'; import OptionsListSkeletonView from '@components/OptionsListSkeletonView'; @@ -28,14 +26,11 @@ type SidebarLinksProps = { /** List of options to display */ optionListItems: Report[]; - /** The chat priority mode */ - priorityMode?: OnyxEntry>; - /** Method to change currently active report */ isActiveReport: (reportID: string) => boolean; }; -function SidebarLinks({insets, optionListItems, priorityMode = CONST.PRIORITY_MODE.DEFAULT, isActiveReport}: SidebarLinksProps) { +function SidebarLinks({insets, optionListItems, isActiveReport}: SidebarLinksProps) { const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); const {shouldUseNarrowLayout} = useResponsiveLayout(); @@ -74,8 +69,6 @@ function SidebarLinks({insets, optionListItems, priorityMode = CONST.PRIORITY_MO [shouldUseNarrowLayout, isActiveReport], ); - const viewMode = priorityMode === CONST.PRIORITY_MODE.GSD ? CONST.OPTION_MODE.COMPACT : CONST.OPTION_MODE.DEFAULT; - const sidebarSkeletonReasonAttributes: SkeletonSpanReasonAttributes = { context: 'SidebarLinks', isLoadingReportData, @@ -101,7 +94,7 @@ function SidebarLinks({insets, optionListItems, priorityMode = CONST.PRIORITY_MO data={optionListItems} onSelectRow={showReportPage} shouldDisableFocusOptions={shouldUseNarrowLayout} - optionMode={viewMode} + optionMode={CONST.OPTION_MODE.DEFAULT} onFirstItemRendered={setSidebarLoaded} /> )} diff --git a/src/pages/inbox/sidebar/SidebarLinksData.tsx b/src/pages/inbox/sidebar/SidebarLinksData.tsx index 1c4879c15884..39e75e8a5396 100644 --- a/src/pages/inbox/sidebar/SidebarLinksData.tsx +++ b/src/pages/inbox/sidebar/SidebarLinksData.tsx @@ -4,12 +4,10 @@ import React, {useCallback, useEffect, useRef} from 'react'; import {View} from 'react-native'; import type {EdgeInsets} from 'react-native-safe-area-context'; import useLocalize from '@hooks/useLocalize'; -import useOnyx from '@hooks/useOnyx'; import {useSidebarOrderedReportsState} from '@hooks/useSidebarOrderedReports'; import useThemeStyles from '@hooks/useThemeStyles'; import {cancelSpan, endSpan, getSpan} from '@libs/telemetry/activeSpans'; import CONST from '@src/CONST'; -import ONYXKEYS from '@src/ONYXKEYS'; import SidebarLinks from './SidebarLinks'; type SidebarLinksDataProps = { @@ -21,7 +19,6 @@ function SidebarLinksData({insets}: SidebarLinksDataProps) { const isFocused = useIsFocused(); const styles = useThemeStyles(); const {translate} = useLocalize(); - const [priorityMode = CONST.PRIORITY_MODE.DEFAULT] = useOnyx(ONYXKEYS.NVP_PRIORITY_MODE); const {orderedReports, currentReportID} = useSidebarOrderedReportsState('SidebarLinksData'); @@ -75,10 +72,7 @@ function SidebarLinksData({insets}: SidebarLinksDataProps) { onLayout={onLayout} > diff --git a/src/pages/settings/Preferences/PreferencesPage.tsx b/src/pages/settings/Preferences/PreferencesPage.tsx index b3426c834830..28bdc3836966 100755 --- a/src/pages/settings/Preferences/PreferencesPage.tsx +++ b/src/pages/settings/Preferences/PreferencesPage.tsx @@ -32,8 +32,6 @@ function PreferencesPage() { const {getCurrencySymbol} = useCurrencyListActions(); const illustrations = useMemoizedLazyIllustrations(['Gears']); const preferencesIllustration = usePreferencesSectionIllustration(); - const [priorityMode] = useOnyx(ONYXKEYS.NVP_PRIORITY_MODE); - const platform = getPlatform(true); const [mutedPlatforms = getEmptyObject>>()] = useOnyx(ONYXKEYS.NVP_MUTED_PLATFORMS); const isPlatformMuted = mutedPlatforms[platform]; @@ -113,14 +111,6 @@ function PreferencesPage() { /> - Navigation.navigate(ROUTES.SETTINGS_PRIORITY_MODE)} - wrapperStyle={styles.sectionMenuItemTopDescription} - sentryLabel={CONST.SENTRY_LABEL.SETTINGS_PREFERENCES.PRIORITY_MODE} - /> ; - text: string; - alternateText: string; - keyForList: ValueOf; - isSelected: boolean; -}; - -function PriorityModePage() { - const {translate} = useLocalize(); - const [priorityMode = CONST.PRIORITY_MODE.DEFAULT] = useOnyx(ONYXKEYS.NVP_PRIORITY_MODE); - const styles = useThemeStyles(); - const priorityModes = Object.values(CONST.PRIORITY_MODE).map((mode) => ({ - value: mode, - text: translate(`priorityModePage.priorityModes.${mode}.label`), - alternateText: translate(`priorityModePage.priorityModes.${mode}.description`), - keyForList: mode, - isSelected: priorityMode === mode, - })); - - const updateMode = useCallback( - (mode: PriorityModeItem) => { - if (mode.value === priorityMode) { - Navigation.goBack(); - return; - } - updateChatPriorityMode(mode.value); - }, - [priorityMode], - ); - - return ( - - Navigation.goBack()} - /> - {translate('priorityModePage.explainerText')} - mode.isSelected)?.keyForList} - /> - - ); -} - -export default PriorityModePage; diff --git a/src/styles/index.ts b/src/styles/index.ts index 71ec72fea456..eae83119cc2f 100644 --- a/src/styles/index.ts +++ b/src/styles/index.ts @@ -4359,6 +4359,11 @@ const staticStyles = (theme: ThemeColors) => scrollMarginInline: variables.tabSelectorScrollMarginInline, }, + tabSelectorButtonSmall: { + height: variables.componentSizeSmall, + paddingHorizontal: 12, + }, + tabSelector: { flexDirection: 'row', paddingHorizontal: 20, @@ -4371,6 +4376,11 @@ const staticStyles = (theme: ThemeColors) => paddingHorizontal: 20, }, + tabSelectorContentContainerSmall: { + paddingTop: 4, + paddingBottom: 4, + }, + scrollableTabSelector: { flexGrow: 0, }, @@ -6209,6 +6219,11 @@ const dynamicStyles = (theme: ThemeColors) => fontSize: variables.fontSizeLabel, }) satisfies TextStyle, + tabTextSmall: { + fontSize: variables.fontSizeSmall, + lineHeight: 16, + } satisfies TextStyle, + tabBackground: (hovered: boolean, isFocused: boolean, isDisabled: boolean, background: string | Animated.AnimatedInterpolation) => { if (isDisabled) { return {backgroundColor: undefined}; diff --git a/src/types/onyx/InboxTab.ts b/src/types/onyx/InboxTab.ts new file mode 100644 index 000000000000..616f5b89f764 --- /dev/null +++ b/src/types/onyx/InboxTab.ts @@ -0,0 +1,6 @@ +import type {ValueOf} from 'type-fest'; +import type CONST from '@src/CONST'; + +type InboxTab = ValueOf; + +export default InboxTab; diff --git a/src/types/onyx/PriorityMode.ts b/src/types/onyx/PriorityMode.ts deleted file mode 100644 index 404c678945ad..000000000000 --- a/src/types/onyx/PriorityMode.ts +++ /dev/null @@ -1,8 +0,0 @@ -import type {OnyxEntry} from 'react-native-onyx'; -import type {ValueOf} from 'type-fest'; -import type CONST from '@src/CONST'; - -/** Modes that define how the user's chats are displayed in his chat list */ -type PriorityMode = OnyxEntry>; - -export default PriorityMode; diff --git a/tests/actions/QueuedOnyxUpdatesTest.ts b/tests/actions/QueuedOnyxUpdatesTest.ts index 5a7190f42945..10c0b93c3f7b 100644 --- a/tests/actions/QueuedOnyxUpdatesTest.ts +++ b/tests/actions/QueuedOnyxUpdatesTest.ts @@ -100,7 +100,6 @@ describe('actions/QueuedOnyxUpdates', () => { await flushQueue(); - await testOnyxKeyValue(ONYXKEYS.NVP_TRY_FOCUS_MODE); await testOnyxKeyValue(ONYXKEYS.PREFERRED_THEME); await testOnyxKeyValue(ONYXKEYS.NVP_PREFERRED_LOCALE); await testOnyxKeyValue(ONYXKEYS.SESSION); @@ -145,7 +144,6 @@ describe('actions/QueuedOnyxUpdates', () => { await flushQueue(); - await testOnyxKeyValue(ONYXKEYS.NVP_TRY_FOCUS_MODE); await testOnyxKeyValue(ONYXKEYS.PREFERRED_THEME); await testOnyxKeyValue(ONYXKEYS.NVP_PREFERRED_LOCALE); await testOnyxKeyValue(ONYXKEYS.SESSION); diff --git a/tests/perf-test/SidebarLinks.perf-test.tsx b/tests/perf-test/SidebarLinks.perf-test.tsx index 34c7b84e2b6b..f26496ca27e3 100644 --- a/tests/perf-test/SidebarLinks.perf-test.tsx +++ b/tests/perf-test/SidebarLinks.perf-test.tsx @@ -80,7 +80,7 @@ describe('SidebarLinks', () => { await Onyx.multiSet({ [ONYXKEYS.PERSONAL_DETAILS_LIST]: LHNTestUtils.fakePersonalDetails, [ONYXKEYS.BETAS]: [CONST.BETAS.DEFAULT_ROOMS], - [ONYXKEYS.NVP_PRIORITY_MODE]: CONST.PRIORITY_MODE.GSD, + [ONYXKEYS.IS_LOADING_REPORT_DATA]: false, ...mockedResponseMap, }); @@ -102,7 +102,7 @@ describe('SidebarLinks', () => { await Onyx.multiSet({ [ONYXKEYS.PERSONAL_DETAILS_LIST]: LHNTestUtils.fakePersonalDetails, [ONYXKEYS.BETAS]: [CONST.BETAS.DEFAULT_ROOMS], - [ONYXKEYS.NVP_PRIORITY_MODE]: CONST.PRIORITY_MODE.GSD, + [ONYXKEYS.IS_LOADING_REPORT_DATA]: false, ...mockedResponseMap, }); diff --git a/tests/ui/LHNItemsPresence.tsx b/tests/ui/LHNItemsPresence.tsx index a6aa9fd36dbb..2241f4654d7a 100644 --- a/tests/ui/LHNItemsPresence.tsx +++ b/tests/ui/LHNItemsPresence.tsx @@ -138,7 +138,6 @@ describe('SidebarLinksData', () => { await waitForBatchedUpdates(); await act(async () => { await Onyx.multiSet({ - [ONYXKEYS.NVP_PRIORITY_MODE]: CONST.PRIORITY_MODE.GSD, [ONYXKEYS.BETAS]: betas, [ONYXKEYS.PERSONAL_DETAILS_LIST]: LHNTestUtils.fakePersonalDetails, [ONYXKEYS.IS_LOADING_APP]: false, @@ -396,9 +395,7 @@ describe('SidebarLinksData', () => { await waitForBatchedUpdatesWithAct(); - // When the user is in the default mode await act(async () => { - await Onyx.merge(ONYXKEYS.NVP_PRIORITY_MODE, CONST.PRIORITY_MODE.DEFAULT); await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${archivedReport.reportID}`, reportNameValuePairs); }); @@ -440,12 +437,6 @@ describe('SidebarLinksData', () => { await waitForBatchedUpdatesWithAct(); // When the user is in focus mode - await act(async () => { - await Onyx.merge(ONYXKEYS.NVP_PRIORITY_MODE, CONST.PRIORITY_MODE.GSD); - }); - - await waitForBatchedUpdatesWithAct(); - // Then the report should appear in the sidebar because it's unread expect(getOptionRows()).toHaveLength(1); @@ -704,25 +695,8 @@ describe('SidebarLinksData', () => { await waitForBatchedUpdatesWithAct(); - // And the user is in default mode - await act(async () => { - await Onyx.merge(ONYXKEYS.NVP_PRIORITY_MODE, CONST.PRIORITY_MODE.DEFAULT); - }); - - await waitForBatchedUpdatesWithAct(); - // Then the report should appear in the sidebar expect(getOptionRows()).toHaveLength(1); - - await act(async () => { - // When the user is in focus mode - await Onyx.merge(ONYXKEYS.NVP_PRIORITY_MODE, CONST.PRIORITY_MODE.GSD); - }); - - await waitForBatchedUpdatesWithAct(); - - // Then the report should not disappear in the sidebar because it's read - expect(getOptionRows()).toHaveLength(0); }); it('should not display an empty submitted report having only a CREATED action', async () => { diff --git a/tests/unit/ReportUtilsTest.ts b/tests/unit/ReportUtilsTest.ts index fce1994b920e..847038475834 100644 --- a/tests/unit/ReportUtilsTest.ts +++ b/tests/unit/ReportUtilsTest.ts @@ -5881,14 +5881,12 @@ describe('ReportUtils', () => { it('should return true when the report is current active report', () => { const report = LHNTestUtils.getFakeReport(); const currentReportId = report.reportID; - const isInFocusMode = true; const betas = [CONST.BETAS.DEFAULT_ROOMS]; expect( shouldReportBeInOptionList({ report, chatReport: mockedChatReport, currentReportId, - isInFocusMode, betas, doesReportHaveViolations: false, excludeEmptyChats: false, @@ -5901,8 +5899,6 @@ describe('ReportUtils', () => { it('should return true for empty submitted report if it is the current focused report', async () => { const report: Report = {...LHNTestUtils.getFakeReport(), total: 0, statusNum: CONST.REPORT.STATUS_NUM.SUBMITTED, stateNum: CONST.REPORT.STATE_NUM.SUBMITTED}; const currentReportId = report.reportID; - - const isInFocusMode = true; const betas = [CONST.BETAS.DEFAULT_ROOMS]; const createdReportAction: ReportAction = {...LHNTestUtils.getFakeReportAction(), actionName: CONST.REPORT.ACTIONS.TYPE.CREATED}; await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report.reportID}`, {[createdReportAction.reportActionID]: createdReportAction}); @@ -5912,7 +5908,6 @@ describe('ReportUtils', () => { report, chatReport: mockedChatReport, currentReportId, - isInFocusMode, betas, doesReportHaveViolations: false, excludeEmptyChats: false, @@ -5930,8 +5925,6 @@ describe('ReportUtils', () => { stateNum: CONST.REPORT.STATE_NUM.SUBMITTED, }; const currentReportId = `${report.reportID}1`; - - const isInFocusMode = true; const betas = [CONST.BETAS.DEFAULT_ROOMS]; const createdReportAction: ReportAction = {...LHNTestUtils.getFakeReportAction(), actionName: CONST.REPORT.ACTIONS.TYPE.CREATED}; await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report.reportID}`, {[createdReportAction.reportActionID]: createdReportAction}); @@ -5941,7 +5934,6 @@ describe('ReportUtils', () => { report, chatReport: mockedChatReport, currentReportId, - isInFocusMode, betas, doesReportHaveViolations: false, excludeEmptyChats: false, @@ -5982,7 +5974,6 @@ describe('ReportUtils', () => { }); const transactionThreadReport = buildTransactionThread(expenseCreatedAction1, expenseReport); const currentReportId = '1'; - const isInFocusMode = false; const betas = [CONST.BETAS.DEFAULT_ROOMS]; await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${expenseReport.reportID}`, expenseReport); await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${expenseReport.reportID}`, { @@ -5994,7 +5985,6 @@ describe('ReportUtils', () => { report: transactionThreadReport, chatReport: mockedChatReport, currentReportId, - isInFocusMode, betas, doesReportHaveViolations: true, excludeEmptyChats: false, @@ -6010,14 +6000,12 @@ describe('ReportUtils', () => { hasOutstandingChildRequest: true, }; const currentReportId = '3'; - const isInFocusMode = true; const betas = [CONST.BETAS.DEFAULT_ROOMS]; expect( shouldReportBeInOptionList({ report: chatReport, chatReport: mockedChatReport, currentReportId, - isInFocusMode, betas, doesReportHaveViolations: false, excludeEmptyChats: false, @@ -6030,7 +6018,6 @@ describe('ReportUtils', () => { it('should return true when the report has valid draft comment', async () => { const report = LHNTestUtils.getFakeReport(); const currentReportId = '3'; - const isInFocusMode = false; const betas = [CONST.BETAS.DEFAULT_ROOMS]; await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_DRAFT_COMMENT}${report.reportID}`, 'fake draft'); @@ -6040,7 +6027,6 @@ describe('ReportUtils', () => { report, chatReport: mockedChatReport, currentReportId, - isInFocusMode, betas, doesReportHaveViolations: false, excludeEmptyChats: false, @@ -6056,14 +6042,12 @@ describe('ReportUtils', () => { isPinned: true, }; const currentReportId = '3'; - const isInFocusMode = false; const betas = [CONST.BETAS.DEFAULT_ROOMS]; expect( shouldReportBeInOptionList({ report, chatReport: mockedChatReport, currentReportId, - isInFocusMode, betas, doesReportHaveViolations: false, excludeEmptyChats: false, @@ -6087,7 +6071,6 @@ describe('ReportUtils', () => { lastMessageText: 'fake', }; const currentReportId = '3'; - const isInFocusMode = true; const betas = [CONST.BETAS.DEFAULT_ROOMS]; await Onyx.merge(ONYXKEYS.SESSION, { @@ -6099,7 +6082,6 @@ describe('ReportUtils', () => { report, chatReport: mockedChatReport, currentReportId, - isInFocusMode, betas, doesReportHaveViolations: false, excludeEmptyChats: false, @@ -6119,7 +6101,6 @@ describe('ReportUtils', () => { private_isArchived: DateUtils.getDBTime(), }; const currentReportId = '3'; - const isInFocusMode = false; const betas = [CONST.BETAS.DEFAULT_ROOMS]; await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${archivedReport.reportID}`, reportNameValuePairs); @@ -6130,7 +6111,6 @@ describe('ReportUtils', () => { report: archivedReport, chatReport: mockedChatReport, currentReportId, - isInFocusMode, betas, doesReportHaveViolations: false, excludeEmptyChats: false, @@ -6150,7 +6130,6 @@ describe('ReportUtils', () => { private_isArchived: DateUtils.getDBTime(), }; const currentReportId = '3'; - const isInFocusMode = true; const betas = [CONST.BETAS.DEFAULT_ROOMS]; await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${archivedReport.reportID}`, reportNameValuePairs); @@ -6161,7 +6140,6 @@ describe('ReportUtils', () => { report: archivedReport, chatReport: mockedChatReport, currentReportId, - isInFocusMode, betas, doesReportHaveViolations: false, excludeEmptyChats: false, @@ -6177,7 +6155,6 @@ describe('ReportUtils', () => { chatType: CONST.REPORT.CHAT_TYPE.SELF_DM, }; const currentReportId = '3'; - const isInFocusMode = false; const betas = [CONST.BETAS.DEFAULT_ROOMS]; const includeSelfDM = true; expect( @@ -6185,7 +6162,6 @@ describe('ReportUtils', () => { report, chatReport: mockedChatReport, currentReportId, - isInFocusMode, betas, doesReportHaveViolations: false, excludeEmptyChats: false, @@ -6206,14 +6182,12 @@ describe('ReportUtils', () => { }, }; const currentReportId = ''; - const isInFocusMode = true; const betas = [CONST.BETAS.DEFAULT_ROOMS]; expect( shouldReportBeInOptionList({ report, chatReport: mockedChatReport, currentReportId, - isInFocusMode, betas, doesReportHaveViolations: false, excludeEmptyChats: false, @@ -6226,14 +6200,12 @@ describe('ReportUtils', () => { it('should return false when the report does not have participants', () => { const report = LHNTestUtils.getFakeReport([]); const currentReportId = ''; - const isInFocusMode = true; const betas = [CONST.BETAS.DEFAULT_ROOMS]; expect( shouldReportBeInOptionList({ report, chatReport: mockedChatReport, currentReportId, - isInFocusMode, betas, doesReportHaveViolations: false, excludeEmptyChats: false, @@ -6249,14 +6221,12 @@ describe('ReportUtils', () => { chatType: CONST.REPORT.CHAT_TYPE.DOMAIN_ALL, }; const currentReportId = ''; - const isInFocusMode = false; const betas: Beta[] = []; expect( shouldReportBeInOptionList({ report, chatReport: mockedChatReport, currentReportId, - isInFocusMode, betas, doesReportHaveViolations: false, excludeEmptyChats: false, @@ -6288,7 +6258,6 @@ describe('ReportUtils', () => { const transactionThreadReport = buildTransactionThread(expenseCreatedAction, expenseReport); expenseCreatedAction.childReportID = transactionThreadReport.reportID; const currentReportId = '1'; - const isInFocusMode = false; const betas = [CONST.BETAS.DEFAULT_ROOMS]; await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${expenseReport.reportID}`, expenseReport); await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${expenseReport.reportID}`, { @@ -6299,7 +6268,6 @@ describe('ReportUtils', () => { report: transactionThreadReport, chatReport: mockedChatReport, currentReportId, - isInFocusMode, betas, doesReportHaveViolations: false, excludeEmptyChats: false, @@ -6312,14 +6280,12 @@ describe('ReportUtils', () => { it('should return false when the report is empty chat and the excludeEmptyChats setting is true', () => { const report = LHNTestUtils.getFakeReport(); const currentReportId = ''; - const isInFocusMode = false; const betas = [CONST.BETAS.DEFAULT_ROOMS]; expect( shouldReportBeInOptionList({ report, chatReport: mockedChatReport, currentReportId, - isInFocusMode, betas, doesReportHaveViolations: false, excludeEmptyChats: true, @@ -6336,7 +6302,6 @@ describe('ReportUtils', () => { reportID: conciergeReportID, }; const currentReportId = ''; - const isInFocusMode = false; const betas = [CONST.BETAS.DEFAULT_ROOMS]; await Onyx.set(ONYXKEYS.CONCIERGE_REPORT_ID, conciergeReportID); @@ -6347,7 +6312,6 @@ describe('ReportUtils', () => { report, chatReport: mockedChatReport, currentReportId, - isInFocusMode, betas, doesReportHaveViolations: false, excludeEmptyChats: true, @@ -6360,14 +6324,12 @@ describe('ReportUtils', () => { it('should return false when the users email is domain-based and the includeDomainEmail is false', () => { const report = LHNTestUtils.getFakeReport(); const currentReportId = ''; - const isInFocusMode = false; const betas = [CONST.BETAS.DEFAULT_ROOMS]; expect( shouldReportBeInOptionList({ report, chatReport: mockedChatReport, currentReportId, - isInFocusMode, betas, doesReportHaveViolations: false, login: '+@domain.com', @@ -6402,7 +6364,6 @@ describe('ReportUtils', () => { report.parentReportID = parentReport.reportID; report.parentReportActionID = parentReportAction.reportActionID; const currentReportId = ''; - const isInFocusMode = false; const betas = [CONST.BETAS.DEFAULT_ROOMS]; await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${parentReport.reportID}`, parentReport); @@ -6415,7 +6376,6 @@ describe('ReportUtils', () => { report, chatReport: mockedChatReport, currentReportId, - isInFocusMode, betas, doesReportHaveViolations: false, excludeEmptyChats: false, @@ -6428,14 +6388,12 @@ describe('ReportUtils', () => { it('should return false when the report is read and we are in the focus mode', () => { const report = LHNTestUtils.getFakeReport(); const currentReportId = ''; - const isInFocusMode = true; const betas = [CONST.BETAS.DEFAULT_ROOMS]; expect( shouldReportBeInOptionList({ report, chatReport: mockedChatReport, currentReportId, - isInFocusMode, betas, doesReportHaveViolations: false, excludeEmptyChats: false, @@ -6469,7 +6427,6 @@ describe('ReportUtils', () => { report, chatReport: mockedChatReport, currentReportId: '', - isInFocusMode: false, betas: [], doesReportHaveViolations: false, excludeEmptyChats: true, @@ -6500,7 +6457,6 @@ describe('ReportUtils', () => { report, chatReport: mockedChatReport, currentReportId: '', - isInFocusMode: true, betas: [CONST.BETAS.DEFAULT_ROOMS], doesReportHaveViolations: false, excludeEmptyChats: false, @@ -6529,7 +6485,6 @@ describe('ReportUtils', () => { report, chatReport: mockedChatReport, currentReportId: '', - isInFocusMode: true, betas: [CONST.BETAS.DEFAULT_ROOMS], doesReportHaveViolations: false, excludeEmptyChats: false, @@ -6551,7 +6506,6 @@ describe('ReportUtils', () => { report, chatReport: mockedChatReport, currentReportId: '', - isInFocusMode: false, betas: [CONST.BETAS.DEFAULT_ROOMS], doesReportHaveViolations: false, excludeEmptyChats: false, @@ -6595,7 +6549,6 @@ describe('ReportUtils', () => { report: threadReport, chatReport: mockedChatReport, currentReportId: '', - isInFocusMode: false, betas: [CONST.BETAS.DEFAULT_ROOMS], doesReportHaveViolations: false, excludeEmptyChats: false, @@ -6646,7 +6599,6 @@ describe('ReportUtils', () => { report: threadReport, chatReport: mockedChatReport, currentReportId: '', - isInFocusMode: false, betas: [CONST.BETAS.DEFAULT_ROOMS], doesReportHaveViolations: false, excludeEmptyChats: false, @@ -6667,7 +6619,6 @@ describe('ReportUtils', () => { report, chatReport: mockedChatReport, currentReportId: '', - isInFocusMode: false, betas: [CONST.BETAS.DEFAULT_ROOMS], doesReportHaveViolations: false, excludeEmptyChats: false, @@ -6689,7 +6640,6 @@ describe('ReportUtils', () => { report, chatReport: mockedChatReport, currentReportId: '', - isInFocusMode: false, betas: [CONST.BETAS.DEFAULT_ROOMS], doesReportHaveViolations: false, excludeEmptyChats: true, @@ -6710,7 +6660,6 @@ describe('ReportUtils', () => { report, chatReport: mockedChatReport, currentReportId: '', - isInFocusMode: false, betas: [CONST.BETAS.DEFAULT_ROOMS], doesReportHaveViolations: false, excludeEmptyChats: false, @@ -6731,7 +6680,6 @@ describe('ReportUtils', () => { report, chatReport: mockedChatReport, currentReportId: '999', - isInFocusMode: false, betas: [CONST.BETAS.DEFAULT_ROOMS], doesReportHaveViolations: false, excludeEmptyChats: false, @@ -6752,7 +6700,6 @@ describe('ReportUtils', () => { report, chatReport: mockedChatReport, currentReportId: '', - isInFocusMode: false, betas: [CONST.BETAS.DEFAULT_ROOMS], doesReportHaveViolations: false, excludeEmptyChats: false, @@ -6768,7 +6715,6 @@ describe('ReportUtils', () => { report: null as unknown as Report, chatReport: mockedChatReport, currentReportId: '', - isInFocusMode: false, betas: [CONST.BETAS.DEFAULT_ROOMS], doesReportHaveViolations: false, excludeEmptyChats: false, @@ -6824,7 +6770,6 @@ describe('ReportUtils', () => { report: transactionThreadReport, chatReport: mockedChatReport, currentReportId: '', - isInFocusMode: false, betas: [CONST.BETAS.DEFAULT_ROOMS], doesReportHaveViolations: true, excludeEmptyChats: false, @@ -11986,7 +11931,6 @@ describe('ReportUtils', () => { report: chatReport, chatReport, currentReportId: '', - isInFocusMode: false, betas: [CONST.BETAS.DEFAULT_ROOMS], doesReportHaveViolations: false, excludeEmptyChats: false, @@ -12044,7 +11988,6 @@ describe('ReportUtils', () => { report: chatReport, chatReport, currentReportId: '', - isInFocusMode: false, betas: [CONST.BETAS.DEFAULT_ROOMS], doesReportHaveViolations: false, excludeEmptyChats: false, @@ -12151,7 +12094,6 @@ describe('ReportUtils', () => { report: expenseReport, chatReport: undefined, currentReportId: undefined, - isInFocusMode: true, betas: undefined, doesReportHaveViolations: false, excludeEmptyChats: false, @@ -12194,7 +12136,6 @@ describe('ReportUtils', () => { report: expenseReport, chatReport: undefined, currentReportId: undefined, - isInFocusMode: true, betas: undefined, doesReportHaveViolations: false, excludeEmptyChats: false, @@ -12545,7 +12486,6 @@ describe('ReportUtils', () => { report: chatReport, chatReport, currentReportId: '', - isInFocusMode: false, betas: [CONST.BETAS.DEFAULT_ROOMS], doesReportHaveViolations: shouldShowRBR, excludeEmptyChats: false, @@ -12728,7 +12668,6 @@ describe('ReportUtils', () => { report: chatReport, chatReport, currentReportId: '', - isInFocusMode: false, betas: [CONST.BETAS.DEFAULT_ROOMS], doesReportHaveViolations: false, excludeEmptyChats: false, @@ -12913,7 +12852,6 @@ describe('ReportUtils', () => { report: chatReport, chatReport, currentReportId: '', - isInFocusMode: false, betas: [CONST.BETAS.DEFAULT_ROOMS], doesReportHaveViolations: false, excludeEmptyChats: false, @@ -12935,7 +12873,6 @@ describe('ReportUtils', () => { report: chatReport, chatReport, currentReportId: '', - isInFocusMode: false, betas: [CONST.BETAS.DEFAULT_ROOMS], doesReportHaveViolations: false, excludeEmptyChats: false, @@ -13036,7 +12973,6 @@ describe('ReportUtils', () => { report: expenseReportChat, chatReport: expenseReportChat, currentReportId: '', - isInFocusMode: false, betas: [CONST.BETAS.DEFAULT_ROOMS], doesReportHaveViolations: false, excludeEmptyChats: false, @@ -13076,7 +13012,6 @@ describe('ReportUtils', () => { report: expenseReportChat, chatReport: expenseReportChat, currentReportId: '', - isInFocusMode: false, betas: [CONST.BETAS.DEFAULT_ROOMS], doesReportHaveViolations: false, excludeEmptyChats: false, @@ -13552,7 +13487,6 @@ describe('ReportUtils', () => { report: chatReport, chatReport, currentReportId: '', - isInFocusMode: false, betas: [CONST.BETAS.DEFAULT_ROOMS], doesReportHaveViolations: false, excludeEmptyChats: false, @@ -13619,7 +13553,6 @@ describe('ReportUtils', () => { report: chatReport, chatReport, currentReportId: '', - isInFocusMode: false, betas: [CONST.BETAS.DEFAULT_ROOMS], doesReportHaveViolations: false, excludeEmptyChats: false, @@ -13690,7 +13623,6 @@ describe('ReportUtils', () => { report: chatReport, chatReport, currentReportId: '', - isInFocusMode: false, betas: [CONST.BETAS.DEFAULT_ROOMS], doesReportHaveViolations: false, excludeEmptyChats: false, @@ -13760,7 +13692,6 @@ describe('ReportUtils', () => { report: chatReport, chatReport, currentReportId: '', - isInFocusMode: false, betas: [CONST.BETAS.DEFAULT_ROOMS], doesReportHaveViolations: false, excludeEmptyChats: false, @@ -15525,7 +15456,6 @@ describe('ReportUtils', () => { report: policyExpenseChat, chatReport: policyExpenseChat, currentReportId: '', - isInFocusMode: false, betas: [CONST.BETAS.DEFAULT_ROOMS], doesReportHaveViolations: false, excludeEmptyChats: false, @@ -15594,7 +15524,6 @@ describe('ReportUtils', () => { report: policyExpenseChat, chatReport: policyExpenseChat, currentReportId: '', - isInFocusMode: false, betas: [CONST.BETAS.DEFAULT_ROOMS], doesReportHaveViolations: false, excludeEmptyChats: false, diff --git a/tests/unit/SidebarFilterTest.ts b/tests/unit/SidebarFilterTest.ts index 1970472dfdc3..c377ef0c6e25 100644 --- a/tests/unit/SidebarFilterTest.ts +++ b/tests/unit/SidebarFilterTest.ts @@ -17,7 +17,6 @@ jest.mock('@libs/Permissions'); const ONYXKEYS = { PERSONAL_DETAILS_LIST: 'personalDetailsList', IS_LOADING_APP: 'isLoadingApp', - NVP_PRIORITY_MODE: 'nvp_priorityMode', SESSION: 'session', BETAS: 'betas', COLLECTION: { @@ -372,7 +371,6 @@ xdescribe('Sidebar', () => { // When Onyx is updated to contain that data and the sidebar re-renders .then(() => Onyx.multiSet({ - [ONYXKEYS.NVP_PRIORITY_MODE]: CONST.PRIORITY_MODE.GSD, [ONYXKEYS.BETAS]: betas, [ONYXKEYS.PERSONAL_DETAILS_LIST]: LHNTestUtils.fakePersonalDetails, [ONYXKEYS.IS_LOADING_APP]: false, @@ -421,7 +419,6 @@ xdescribe('Sidebar', () => { // When Onyx is updated to contain that data and the sidebar re-renders .then(() => Onyx.multiSet({ - [ONYXKEYS.NVP_PRIORITY_MODE]: CONST.PRIORITY_MODE.GSD, [ONYXKEYS.PERSONAL_DETAILS_LIST]: LHNTestUtils.fakePersonalDetails, [ONYXKEYS.IS_LOADING_APP]: false, ...reportCollectionDataSet, @@ -492,7 +489,6 @@ xdescribe('Sidebar', () => { // When Onyx is updated to contain that data and the sidebar re-renders .then(() => Onyx.multiSet({ - [ONYXKEYS.NVP_PRIORITY_MODE]: CONST.PRIORITY_MODE.GSD, [ONYXKEYS.PERSONAL_DETAILS_LIST]: LHNTestUtils.fakePersonalDetails, [ONYXKEYS.IS_LOADING_APP]: false, [`${ONYXKEYS.COLLECTION.REPORT_DRAFT_COMMENT}${draftReport.reportID}`]: 'draft report message', @@ -546,7 +542,6 @@ xdescribe('Sidebar', () => { // When Onyx is updated to contain that data and the sidebar re-renders .then(() => Onyx.multiSet({ - [ONYXKEYS.NVP_PRIORITY_MODE]: CONST.PRIORITY_MODE.GSD, [ONYXKEYS.BETAS]: betas, [ONYXKEYS.PERSONAL_DETAILS_LIST]: LHNTestUtils.fakePersonalDetails, [ONYXKEYS.IS_LOADING_APP]: false, @@ -612,7 +607,6 @@ xdescribe('Sidebar', () => { // When Onyx is updated to contain that data and the sidebar re-renders .then(() => Onyx.multiSet({ - [ONYXKEYS.NVP_PRIORITY_MODE]: CONST.PRIORITY_MODE.GSD, [ONYXKEYS.BETAS]: betas, [ONYXKEYS.PERSONAL_DETAILS_LIST]: LHNTestUtils.fakePersonalDetails, [ONYXKEYS.IS_LOADING_APP]: false, @@ -714,7 +708,6 @@ xdescribe('Sidebar', () => { // When Onyx is updated to contain that data and the sidebar re-renders .then(() => Onyx.multiSet({ - [ONYXKEYS.NVP_PRIORITY_MODE]: CONST.PRIORITY_MODE.GSD, [ONYXKEYS.BETAS]: betas, [ONYXKEYS.PERSONAL_DETAILS_LIST]: LHNTestUtils.fakePersonalDetails, [ONYXKEYS.IS_LOADING_APP]: false, @@ -769,7 +762,6 @@ xdescribe('Sidebar', () => { // When Onyx is updated to contain that data and the sidebar re-renders .then(() => Onyx.multiSet({ - [ONYXKEYS.NVP_PRIORITY_MODE]: CONST.PRIORITY_MODE.DEFAULT, [ONYXKEYS.BETAS]: betas, [ONYXKEYS.PERSONAL_DETAILS_LIST]: LHNTestUtils.fakePersonalDetails, [ONYXKEYS.IS_LOADING_APP]: false, @@ -822,7 +814,6 @@ xdescribe('Sidebar', () => { // When Onyx is updated to contain that data and the sidebar re-renders .then(() => Onyx.multiSet({ - [ONYXKEYS.NVP_PRIORITY_MODE]: CONST.PRIORITY_MODE.GSD, [ONYXKEYS.BETAS]: betas, [ONYXKEYS.PERSONAL_DETAILS_LIST]: LHNTestUtils.fakePersonalDetails, [ONYXKEYS.IS_LOADING_APP]: false, @@ -873,7 +864,6 @@ xdescribe('Sidebar', () => { // When Onyx is updated to contain that data and the sidebar re-renders .then(() => Onyx.multiSet({ - [ONYXKEYS.NVP_PRIORITY_MODE]: CONST.PRIORITY_MODE.GSD, [ONYXKEYS.BETAS]: betas, [ONYXKEYS.PERSONAL_DETAILS_LIST]: LHNTestUtils.fakePersonalDetails, [ONYXKEYS.IS_LOADING_APP]: false, @@ -920,7 +910,6 @@ xdescribe('Sidebar', () => { // When Onyx is updated to contain that data and the sidebar re-renders .then(() => Onyx.multiSet({ - [ONYXKEYS.NVP_PRIORITY_MODE]: CONST.PRIORITY_MODE.GSD, [ONYXKEYS.BETAS]: betas, [ONYXKEYS.PERSONAL_DETAILS_LIST]: LHNTestUtils.fakePersonalDetails, [ONYXKEYS.IS_LOADING_APP]: false, @@ -956,7 +945,6 @@ xdescribe('Sidebar', () => { // When Onyx is updated to contain that data and the sidebar re-renders .then(() => Onyx.multiSet({ - [ONYXKEYS.NVP_PRIORITY_MODE]: CONST.PRIORITY_MODE.GSD, [ONYXKEYS.PERSONAL_DETAILS_LIST]: LHNTestUtils.fakePersonalDetails, [ONYXKEYS.IS_LOADING_APP]: false, ...reportCollectionDataSet, diff --git a/tests/unit/SidebarOrderTest.ts b/tests/unit/SidebarOrderTest.ts index 11975ca61b92..55a7cfbc6cbe 100644 --- a/tests/unit/SidebarOrderTest.ts +++ b/tests/unit/SidebarOrderTest.ts @@ -122,7 +122,6 @@ describe('Sidebar', () => { // When Onyx is updated with the data and the sidebar re-renders .then(() => Onyx.multiSet({ - [ONYXKEYS.NVP_PRIORITY_MODE]: CONST.PRIORITY_MODE.DEFAULT, [ONYXKEYS.PERSONAL_DETAILS_LIST]: LHNTestUtils.fakePersonalDetails, [ONYXKEYS.IS_LOADING_APP]: false, ...reportCollectionDataSet, @@ -160,7 +159,6 @@ describe('Sidebar', () => { // When Onyx is updated with the data and the sidebar re-renders .then(() => Onyx.multiSet({ - [ONYXKEYS.NVP_PRIORITY_MODE]: CONST.PRIORITY_MODE.DEFAULT, [ONYXKEYS.PERSONAL_DETAILS_LIST]: LHNTestUtils.fakePersonalDetails, [ONYXKEYS.IS_LOADING_APP]: false, ...reportCollectionDataSet, @@ -209,7 +207,6 @@ describe('Sidebar', () => { // When Onyx is updated with the data and the sidebar re-renders .then(() => Onyx.multiSet({ - [ONYXKEYS.NVP_PRIORITY_MODE]: CONST.PRIORITY_MODE.DEFAULT, [ONYXKEYS.PERSONAL_DETAILS_LIST]: LHNTestUtils.fakePersonalDetails, [ONYXKEYS.IS_LOADING_APP]: false, [`${ONYXKEYS.COLLECTION.REPORT_DRAFT_COMMENT}${report1.reportID}`]: 'report1 draft', @@ -256,7 +253,6 @@ describe('Sidebar', () => { // When Onyx is updated with the data and the sidebar re-renders .then(() => Onyx.multiSet({ - [ONYXKEYS.NVP_PRIORITY_MODE]: CONST.PRIORITY_MODE.DEFAULT, [ONYXKEYS.PERSONAL_DETAILS_LIST]: LHNTestUtils.fakePersonalDetails, [ONYXKEYS.IS_LOADING_APP]: false, ...reportCollectionDataSet, @@ -319,7 +315,6 @@ describe('Sidebar', () => { // When Onyx is updated with the data and the sidebar re-renders .then(() => Onyx.multiSet({ - [ONYXKEYS.NVP_PRIORITY_MODE]: CONST.PRIORITY_MODE.DEFAULT, [ONYXKEYS.PERSONAL_DETAILS_LIST]: LHNTestUtils.fakePersonalDetails, [ONYXKEYS.IS_LOADING_APP]: false, ...reportCollectionDataSet, @@ -393,7 +388,6 @@ describe('Sidebar', () => { // When Onyx is updated with the data and the sidebar re-renders .then(() => Onyx.multiSet({ - [ONYXKEYS.NVP_PRIORITY_MODE]: CONST.PRIORITY_MODE.DEFAULT, [ONYXKEYS.PERSONAL_DETAILS_LIST]: LHNTestUtils.fakePersonalDetails, [ONYXKEYS.IS_LOADING_APP]: false, ...reportCollectionDataSet, @@ -472,7 +466,6 @@ describe('Sidebar', () => { // When Onyx is updated with the data and the sidebar re-renders .then(() => Onyx.multiSet({ - [ONYXKEYS.NVP_PRIORITY_MODE]: CONST.PRIORITY_MODE.DEFAULT, [ONYXKEYS.PERSONAL_DETAILS_LIST]: LHNTestUtils.fakePersonalDetails, [ONYXKEYS.IS_LOADING_APP]: false, [`${ONYXKEYS.COLLECTION.POLICY}${fakeReport.policyID}`]: fakePolicy, @@ -523,7 +516,6 @@ describe('Sidebar', () => { // When Onyx is updated with the data and the sidebar re-renders .then(() => Onyx.multiSet({ - [ONYXKEYS.NVP_PRIORITY_MODE]: CONST.PRIORITY_MODE.DEFAULT, [ONYXKEYS.PERSONAL_DETAILS_LIST]: LHNTestUtils.fakePersonalDetails, [ONYXKEYS.IS_LOADING_APP]: false, [ONYXKEYS.COLLECTION.REPORT_DRAFT_COMMENT + report2.reportID]: 'This is a draft', @@ -570,7 +562,6 @@ describe('Sidebar', () => { // When Onyx is updated with the data and the sidebar re-renders .then(() => Onyx.multiSet({ - [ONYXKEYS.NVP_PRIORITY_MODE]: CONST.PRIORITY_MODE.DEFAULT, [ONYXKEYS.PERSONAL_DETAILS_LIST]: LHNTestUtils.fakePersonalDetails, [ONYXKEYS.IS_LOADING_APP]: false, [`${ONYXKEYS.COLLECTION.REPORT_DRAFT_COMMENT}${report.reportID}`]: 'This is a draft', @@ -612,7 +603,6 @@ describe('Sidebar', () => { // When Onyx is updated with the data and the sidebar re-renders .then(() => Onyx.multiSet({ - [ONYXKEYS.NVP_PRIORITY_MODE]: CONST.PRIORITY_MODE.DEFAULT, [ONYXKEYS.PERSONAL_DETAILS_LIST]: LHNTestUtils.fakePersonalDetails, [ONYXKEYS.IS_LOADING_APP]: false, ...reportCollectionDataSet, @@ -696,7 +686,6 @@ describe('Sidebar', () => { // When Onyx is updated with the data and the sidebar re-renders .then(() => Onyx.multiSet({ - [ONYXKEYS.NVP_PRIORITY_MODE]: CONST.PRIORITY_MODE.DEFAULT, [ONYXKEYS.PERSONAL_DETAILS_LIST]: LHNTestUtils.fakePersonalDetails, [ONYXKEYS.IS_LOADING_APP]: false, [`${ONYXKEYS.COLLECTION.REPORT_DRAFT_COMMENT}${report2.reportID}`]: 'Report2 draft comment', @@ -754,7 +743,6 @@ describe('Sidebar', () => { // When Onyx is updated with the data and the sidebar re-renders .then(() => Onyx.multiSet({ - [ONYXKEYS.NVP_PRIORITY_MODE]: CONST.PRIORITY_MODE.DEFAULT, [ONYXKEYS.PERSONAL_DETAILS_LIST]: LHNTestUtils.fakePersonalDetails, [ONYXKEYS.IS_LOADING_APP]: false, ...reportCollectionDataSet, @@ -816,7 +804,6 @@ describe('Sidebar', () => { // When Onyx is updated with the data and the sidebar re-renders .then(() => Onyx.multiSet({ - [ONYXKEYS.NVP_PRIORITY_MODE]: CONST.PRIORITY_MODE.DEFAULT, [ONYXKEYS.PERSONAL_DETAILS_LIST]: LHNTestUtils.fakePersonalDetails, [ONYXKEYS.IS_LOADING_APP]: false, ...reportDraftCommentCollectionDataSet, @@ -888,7 +875,6 @@ describe('Sidebar', () => { .then(() => Onyx.multiSet({ [ONYXKEYS.BETAS]: betas, - [ONYXKEYS.NVP_PRIORITY_MODE]: CONST.PRIORITY_MODE.DEFAULT, [ONYXKEYS.PERSONAL_DETAILS_LIST]: LHNTestUtils.fakePersonalDetails, [ONYXKEYS.IS_LOADING_APP]: false, ...reportNameValuePairsCollectionDataSet, @@ -932,7 +918,6 @@ describe('Sidebar', () => { // When Onyx is updated with the data and the sidebar re-renders .then(() => Onyx.multiSet({ - [ONYXKEYS.NVP_PRIORITY_MODE]: CONST.PRIORITY_MODE.DEFAULT, [ONYXKEYS.PERSONAL_DETAILS_LIST]: LHNTestUtils.fakePersonalDetails, [ONYXKEYS.IS_LOADING_APP]: false, ...reportCollectionDataSet, @@ -973,7 +958,6 @@ describe('Sidebar', () => { // with all reports having unread comments .then(() => Onyx.multiSet({ - [ONYXKEYS.NVP_PRIORITY_MODE]: CONST.PRIORITY_MODE.GSD, [ONYXKEYS.IS_LOADING_APP]: false, ...reportCollectionDataSet, }), @@ -1035,7 +1019,6 @@ describe('Sidebar', () => { .then(() => Onyx.multiSet({ [ONYXKEYS.BETAS]: betas, - [ONYXKEYS.NVP_PRIORITY_MODE]: CONST.PRIORITY_MODE.GSD, [ONYXKEYS.PERSONAL_DETAILS_LIST]: LHNTestUtils.fakePersonalDetails, [ONYXKEYS.IS_LOADING_APP]: false, ...reportNameValuePairsCollectionDataSet, diff --git a/tests/unit/SidebarTest.ts b/tests/unit/SidebarTest.ts index a0fbc44f2995..54a42f4a4ecd 100644 --- a/tests/unit/SidebarTest.ts +++ b/tests/unit/SidebarTest.ts @@ -94,7 +94,7 @@ describe('Sidebar', () => { return act(async () => { await Onyx.multiSet({ [ONYXKEYS.BETAS]: betas, - [ONYXKEYS.NVP_PRIORITY_MODE]: CONST.PRIORITY_MODE.GSD, + [ONYXKEYS.PERSONAL_DETAILS_LIST]: LHNTestUtils.fakePersonalDetails, [ONYXKEYS.IS_LOADING_APP]: false, ...reportNameValuePairsCollection, @@ -157,7 +157,7 @@ describe('Sidebar', () => { return act(async () => { await Onyx.multiSet({ [ONYXKEYS.BETAS]: betas, - [ONYXKEYS.NVP_PRIORITY_MODE]: CONST.PRIORITY_MODE.GSD, + [ONYXKEYS.PERSONAL_DETAILS_LIST]: LHNTestUtils.fakePersonalDetails, [ONYXKEYS.IS_LOADING_APP]: false, ...reportNameValuePairsCollection, diff --git a/tests/unit/SidebarUtilsTest.ts b/tests/unit/SidebarUtilsTest.ts index 31acccfe67cf..e4a52b030dd0 100644 --- a/tests/unit/SidebarUtilsTest.ts +++ b/tests/unit/SidebarUtilsTest.ts @@ -3471,10 +3471,10 @@ describe('SidebarUtils', () => { ]); const mockLocaleCompare = (a: string, b: string) => a.localeCompare(b); - const priorityMode = CONST.PRIORITY_MODE.DEFAULT; + const useAlphabeticalSort = false; // When the reports are sorted - const result = SidebarUtils.sortReportsToDisplayInLHN(reports, priorityMode, mockLocaleCompare, undefined, undefined, undefined); + const result = SidebarUtils.sortReportsToDisplayInLHN(reports, useAlphabeticalSort, mockLocaleCompare, undefined, undefined, undefined); // Then the reports are sorted in the correct order expect(result).toEqual(['0', '1', '2']); // Pinned first, Error second, Normal third @@ -3499,11 +3499,11 @@ describe('SidebarUtils', () => { const mockLocaleCompare = (a: string, b: string) => a.localeCompare(b); - // When the reports are sorted in default mode - const defaultResult = SidebarUtils.sortReportsToDisplayInLHN(reports, CONST.PRIORITY_MODE.DEFAULT, mockLocaleCompare, undefined, undefined, undefined); + // When the reports are sorted in default mode (not alphabetical) + const defaultResult = SidebarUtils.sortReportsToDisplayInLHN(reports, false, mockLocaleCompare, undefined, undefined, undefined); - // When the reports are sorted in GSD mode - const gsdResult = SidebarUtils.sortReportsToDisplayInLHN(reports, CONST.PRIORITY_MODE.GSD, mockLocaleCompare, undefined, undefined, undefined); + // When the reports are sorted in alphabetical mode + const gsdResult = SidebarUtils.sortReportsToDisplayInLHN(reports, true, mockLocaleCompare, undefined, undefined, undefined); // Then the reports are sorted in the correct order expect(defaultResult).toEqual(['1', '0']); // Most recent first (index 1 has later date) @@ -3523,7 +3523,6 @@ describe('SidebarUtils', () => { reports, updatedReportsKeys: [`${ONYXKEYS.COLLECTION.REPORT}999`], currentReportId: '1', - isInFocusMode: false, betas: [], transactions: {}, transactionViolations: {}, @@ -3544,7 +3543,6 @@ describe('SidebarUtils', () => { reports, updatedReportsKeys: ['0'], currentReportId: undefined, - isInFocusMode: false, betas: [], transactions: {}, transactionViolations: {}, From 9af6b204a644f685ae4648f107a917a35497c736 Mon Sep 17 00:00:00 2001 From: Shawn Borton Date: Fri, 17 Apr 2026 15:55:47 +0200 Subject: [PATCH 010/435] Update tabs to All/To do/Expenses/DMs and add Unreads toggle Replace the Unread tab with a "To do" tab that shows items needing action (GBR/RBR indicators). Rename Direct Messages to DMs. Add a separate Unreads toggle in the Inbox header using a small 20px Switch component. The toggle filters to show only unread items and works across all tabs. State persists via NVP_INBOX_UNREAD_FILTER. - Add 'small' size variant to Switch component (36x20 track, 14x14 thumb) - Add switchTrackSmall/switchThumbSmall styles - Add InboxUnreadToggle component with "Unreads" label + small switch - Update filterReportsForInboxTab to apply unread filter orthogonally Co-Authored-By: Claude Opus 4.6 (1M context) --- src/CONST/index.ts | 4 +-- src/ONYXKEYS.ts | 4 +++ src/components/Switch.tsx | 22 ++++++++++---- src/hooks/useSidebarOrderedReports.tsx | 30 ++++++++++++------- src/languages/de.ts | 5 ++-- src/languages/en.ts | 5 ++-- src/languages/es.ts | 5 ++-- src/languages/fr.ts | 5 ++-- src/languages/it.ts | 5 ++-- src/languages/ja.ts | 5 ++-- src/languages/nl.ts | 5 ++-- src/languages/pl.ts | 5 ++-- src/languages/pt-BR.ts | 5 ++-- src/languages/zh-hans.ts | 5 ++-- src/libs/SidebarUtils.ts | 29 ++++++++++-------- src/libs/actions/User.ts | 5 ++++ src/pages/inbox/sidebar/BaseSidebarScreen.tsx | 5 +++- src/pages/inbox/sidebar/InboxTabSelector.tsx | 4 +-- src/pages/inbox/sidebar/InboxUnreadToggle.tsx | 30 +++++++++++++++++++ src/styles/index.ts | 14 +++++++++ 20 files changed, 144 insertions(+), 53 deletions(-) create mode 100644 src/pages/inbox/sidebar/InboxUnreadToggle.tsx diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 3fbca9aa9280..be0e4fd88728 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -2085,9 +2085,9 @@ const CONST = { }, INBOX_TAB: { ALL: 'all', - UNREADS: 'unreads', + TODO: 'todo', EXPENSES: 'expenses', - DIRECT_MESSAGES: 'directMessages', + DMS: 'dms', }, THEME: { DEFAULT: 'system', diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts index b7da30c7c155..3dce345ee6e7 100755 --- a/src/ONYXKEYS.ts +++ b/src/ONYXKEYS.ts @@ -162,6 +162,9 @@ const ONYXKEYS = { /** Contains the user preference for the active inbox tab filter */ NVP_INBOX_TAB: 'nvp_inboxTab', + /** Whether to show only unread chats in the inbox */ + NVP_INBOX_UNREAD_FILTER: 'nvp_inboxUnreadFilter', + /** Contains the users's block expiration (if they have one) */ NVP_BLOCKED_FROM_CONCIERGE: 'nvp_private_blockedFromConcierge', @@ -1354,6 +1357,7 @@ type OnyxValuesMapping = { [ONYXKEYS.BETA_CONFIGURATION]: OnyxTypes.BetaConfiguration; [ONYXKEYS.NVP_MUTED_PLATFORMS]: Partial>; [ONYXKEYS.NVP_INBOX_TAB]: ValueOf; + [ONYXKEYS.NVP_INBOX_UNREAD_FILTER]: boolean; [ONYXKEYS.NVP_BLOCKED_FROM_CONCIERGE]: OnyxTypes.BlockedFromConcierge; [ONYXKEYS.QUEUE_FLUSHED_DATA]: AnyOnyxUpdate[]; [ONYXKEYS.TRANSACTIONS_PENDING_3DS_REVIEW]: OnyxTypes.TransactionsPending3DSReview; diff --git a/src/components/Switch.tsx b/src/components/Switch.tsx index ed82f75422e4..0c888a2efa66 100644 --- a/src/components/Switch.tsx +++ b/src/components/Switch.tsx @@ -26,6 +26,9 @@ type SwitchProps = { /** Callback to fire when the switch is toggled in disabled state */ disabledAction?: () => void; + + /** Size variant. 'small' renders a compact 20px tall switch. */ + size?: 'default' | 'small'; }; const OFFSET_X = { @@ -33,14 +36,21 @@ const OFFSET_X = { ON: 20, }; -function Switch({isOn, onToggle, accessibilityLabel, disabled, showLockIcon, disabledAction}: SwitchProps) { +const OFFSET_X_SMALL = { + OFF: 0, + ON: 16, +}; + +function Switch({isOn, onToggle, accessibilityLabel, disabled, showLockIcon, disabledAction, size}: SwitchProps) { const styles = useThemeStyles(); const {translate} = useLocalize(); - const offsetX = useSharedValue(isOn ? OFFSET_X.ON : OFFSET_X.OFF); + const isSmall = size === 'small'; + const offsets = isSmall ? OFFSET_X_SMALL : OFFSET_X; + const offsetX = useSharedValue(isOn ? offsets.ON : offsets.OFF); const theme = useTheme(); const expensifyIcons = useMemoizedLazyExpensifyIcons(['Lock']); - const targetOffsetX = isOn ? OFFSET_X.ON : OFFSET_X.OFF; + const targetOffsetX = isOn ? offsets.ON : offsets.OFF; const prevIsOn = useRef(isOn); const hasUserToggled = useRef(false); @@ -85,7 +95,7 @@ function Switch({isOn, onToggle, accessibilityLabel, disabled, showLockIcon, dis })); const animatedSwitchTrackStyle = useAnimatedStyle(() => ({ - backgroundColor: interpolateColor(offsetX.get(), [OFFSET_X.OFF, OFFSET_X.ON], [theme.icon, theme.success]), + backgroundColor: interpolateColor(offsetX.get(), [offsets.OFF, offsets.ON], [theme.icon, theme.success]), })); // Enhance accessibility label to include locked state when disabled @@ -109,8 +119,8 @@ function Switch({isOn, onToggle, accessibilityLabel, disabled, showLockIcon, dis pressDimmingValue={0.8} sentryLabel={enhancedAccessibilityLabel} > - - + + {(!!disabled || !!showLockIcon) && ( ; + showUnreadOnly: boolean; }; type SidebarOrderedReportsActionsContextValue = { clearLHNCache: () => void; setActiveTab: (tab: ValueOf) => void; + toggleUnreadFilter: () => void; }; type ReportsToDisplayInLHN = Record; @@ -51,11 +53,13 @@ const SidebarOrderedReportsStateContext = createContext({ clearLHNCache: () => {}, setActiveTab: () => {}, + toggleUnreadFilter: () => {}, }); const policyMapper = (policy: OnyxEntry): PartialPolicyForSidebar => @@ -81,6 +85,8 @@ function SidebarOrderedReportsContextProvider({ const {localeCompare} = useLocalize(); const [inboxTab = CONST.INBOX_TAB.ALL] = useOnyx(ONYXKEYS.NVP_INBOX_TAB); const activeTab = inboxTab ?? CONST.INBOX_TAB.ALL; + const [inboxUnreadFilter = false] = useOnyx(ONYXKEYS.NVP_INBOX_UNREAD_FILTER); + const showUnreadOnly = inboxUnreadFilter ?? false; const [chatReports, {sourceValue: reportUpdates}] = useOnyx(ONYXKEYS.COLLECTION.REPORT); const [policies, {sourceValue: policiesUpdates}] = useMappedPolicies(policyMapper); const [transactions, {sourceValue: transactionsUpdates}] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION); @@ -255,20 +261,18 @@ function SidebarOrderedReportsContextProvider({ setCurrentReportsToDisplay(reportsToDisplayInLHN); }, [reportsToDisplayInLHN]); - const useAlphabeticalSort = activeTab === CONST.INBOX_TAB.UNREADS; - const getOrderedReportIDs = useCallback( - () => SidebarUtils.sortReportsToDisplayInLHN(reportsToDisplayInLHN, useAlphabeticalSort, localeCompare, hasDraftByReportID, reportNameValuePairs, reportAttributes), + () => SidebarUtils.sortReportsToDisplayInLHN(reportsToDisplayInLHN, false, localeCompare, hasDraftByReportID, reportNameValuePairs, reportAttributes), // Rule disabled intentionally - reports should be sorted only when the reportsToDisplayInLHN changes // eslint-disable-next-line react-hooks/exhaustive-deps - [reportsToDisplayInLHN, useAlphabeticalSort, localeCompare, hasDraftByReportID, reportAttributes], + [reportsToDisplayInLHN, localeCompare, hasDraftByReportID, reportAttributes], ); const orderedReportIDs = useMemo(() => getOrderedReportIDs(), [getOrderedReportIDs]); const filteredReportIDs = useMemo( - () => SidebarUtils.filterReportsForInboxTab(orderedReportIDs, reportsToDisplayInLHN, activeTab, hasDraftByReportID, reportNameValuePairs), - [orderedReportIDs, reportsToDisplayInLHN, activeTab, hasDraftByReportID, reportNameValuePairs], + () => SidebarUtils.filterReportsForInboxTab(orderedReportIDs, reportsToDisplayInLHN, activeTab, showUnreadOnly, reportNameValuePairs), + [orderedReportIDs, reportsToDisplayInLHN, activeTab, showUnreadOnly, reportNameValuePairs], ); // Get the actual reports based on the filtered IDs @@ -294,6 +298,10 @@ function SidebarOrderedReportsContextProvider({ setInboxTab(tab); }, []); + const toggleUnreadFilter = useCallback(() => { + setInboxUnreadFilter(!showUnreadOnly); + }, [showUnreadOnly]); + const stateValue: SidebarOrderedReportsStateContextValue = useMemo(() => { // We need to make sure the current report is in the list of reports, but we do not want // to have to re-generate the list every time the currentReportID changes. To do that @@ -312,7 +320,7 @@ function SidebarOrderedReportsContextProvider({ filteredReportIDs.indexOf(derivedCurrentReportID) === -1 ) { const updatedReportIDs = getOrderedReportIDs(); - const updatedFilteredIDs = SidebarUtils.filterReportsForInboxTab(updatedReportIDs, reportsToDisplayInLHN, activeTab, hasDraftByReportID, reportNameValuePairs); + const updatedFilteredIDs = SidebarUtils.filterReportsForInboxTab(updatedReportIDs, reportsToDisplayInLHN, activeTab, showUnreadOnly, reportNameValuePairs); const updatedReports = getOrderedReports(updatedFilteredIDs); return { orderedReports: updatedReports, @@ -320,6 +328,7 @@ function SidebarOrderedReportsContextProvider({ currentReportID: derivedCurrentReportID, chatTabBrickRoad: getChatTabBrickRoad(updatedReportIDs, reportAttributes), activeTab, + showUnreadOnly, }; } @@ -329,6 +338,7 @@ function SidebarOrderedReportsContextProvider({ currentReportID: derivedCurrentReportID, chatTabBrickRoad: getChatTabBrickRoad(orderedReportIDs, reportAttributes), activeTab, + showUnreadOnly, }; }, [ getOrderedReportIDs, @@ -340,12 +350,12 @@ function SidebarOrderedReportsContextProvider({ orderedReports, reportAttributes, activeTab, + showUnreadOnly, reportsToDisplayInLHN, - hasDraftByReportID, reportNameValuePairs, ]); - const actionsValue: SidebarOrderedReportsActionsContextValue = useMemo(() => ({clearLHNCache, setActiveTab}), [clearLHNCache, setActiveTab]); + const actionsValue: SidebarOrderedReportsActionsContextValue = useMemo(() => ({clearLHNCache, setActiveTab, toggleUnreadFilter}), [clearLHNCache, setActiveTab, toggleUnreadFilter]); const currentDeps = { activeTab, diff --git a/src/languages/de.ts b/src/languages/de.ts index 9c9f75001ff2..befcb46025c3 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -2788,9 +2788,10 @@ ${amount} für ${merchant} – ${date}`, }, inboxTabs: { all: 'All', - unreads: 'Unread', + todo: 'To do', expenses: 'Expenses', - directMessages: 'Direct messages', + dms: 'DMs', + unreadToggle: 'Unreads', }, reportDetailsPage: { inWorkspace: (policyName: string) => `in ${policyName}`, diff --git a/src/languages/en.ts b/src/languages/en.ts index 12480bbb55c4..9be6ecbc314c 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -2842,9 +2842,10 @@ const translations = { }, inboxTabs: { all: 'All', - unreads: 'Unread', + todo: 'To do', expenses: 'Expenses', - directMessages: 'Direct messages', + dms: 'DMs', + unreadToggle: 'Unreads', }, reportDetailsPage: { inWorkspace: (policyName: string) => `in ${policyName}`, diff --git a/src/languages/es.ts b/src/languages/es.ts index d9243ca2d933..741f20fc7dfb 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -2659,9 +2659,10 @@ ${amount} para ${merchant} - ${date}`, }, inboxTabs: { all: 'All', - unreads: 'Unread', + todo: 'To do', expenses: 'Expenses', - directMessages: 'Direct messages', + dms: 'DMs', + unreadToggle: 'Unreads', }, reportDetailsPage: { inWorkspace: (policyName) => `en ${policyName}`, diff --git a/src/languages/fr.ts b/src/languages/fr.ts index 57e3862724a6..d90ee798f375 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -2794,9 +2794,10 @@ ${amount} pour ${merchant} - ${date}`, }, inboxTabs: { all: 'All', - unreads: 'Unread', + todo: 'To do', expenses: 'Expenses', - directMessages: 'Direct messages', + dms: 'DMs', + unreadToggle: 'Unreads', }, reportDetailsPage: { inWorkspace: (policyName: string) => `dans ${policyName}`, diff --git a/src/languages/it.ts b/src/languages/it.ts index eea77cc79424..dd14322c29c2 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -2783,9 +2783,10 @@ ${amount} per ${merchant} - ${date}`, }, inboxTabs: { all: 'All', - unreads: 'Unread', + todo: 'To do', expenses: 'Expenses', - directMessages: 'Direct messages', + dms: 'DMs', + unreadToggle: 'Unreads', }, reportDetailsPage: { inWorkspace: (policyName: string) => `in ${policyName}`, diff --git a/src/languages/ja.ts b/src/languages/ja.ts index 455bf7a8850a..1600d5034687 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -2756,9 +2756,10 @@ ${date} の ${merchant} への ${amount}`, }, inboxTabs: { all: 'All', - unreads: 'Unread', + todo: 'To do', expenses: 'Expenses', - directMessages: 'Direct messages', + dms: 'DMs', + unreadToggle: 'Unreads', }, reportDetailsPage: { inWorkspace: (policyName: string) => `${policyName} 内`, diff --git a/src/languages/nl.ts b/src/languages/nl.ts index 4038df5299d4..9617a0d09edb 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -2781,9 +2781,10 @@ ${amount} voor ${merchant} - ${date}`, }, inboxTabs: { all: 'All', - unreads: 'Unread', + todo: 'To do', expenses: 'Expenses', - directMessages: 'Direct messages', + dms: 'DMs', + unreadToggle: 'Unreads', }, reportDetailsPage: { inWorkspace: (policyName: string) => `in ${policyName}`, diff --git a/src/languages/pl.ts b/src/languages/pl.ts index 496974e494b8..4950507eb6d8 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -2774,9 +2774,10 @@ ${amount} dla ${merchant} - ${date}`, }, inboxTabs: { all: 'All', - unreads: 'Unread', + todo: 'To do', expenses: 'Expenses', - directMessages: 'Direct messages', + dms: 'DMs', + unreadToggle: 'Unreads', }, reportDetailsPage: { inWorkspace: (policyName: string) => `w ${policyName}`, diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index 9cecddddfb48..a15d7cc509c4 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -2774,9 +2774,10 @@ ${amount} para ${merchant} - ${date}`, }, inboxTabs: { all: 'All', - unreads: 'Unread', + todo: 'To do', expenses: 'Expenses', - directMessages: 'Direct messages', + dms: 'DMs', + unreadToggle: 'Unreads', }, reportDetailsPage: { inWorkspace: (policyName: string) => `em ${policyName}`, diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index aade94e81823..3db3f03dda5c 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -2706,9 +2706,10 @@ ${amount},商户:${merchant} - 日期:${date}`, }, inboxTabs: { all: 'All', - unreads: 'Unread', + todo: 'To do', expenses: 'Expenses', - directMessages: 'Direct messages', + dms: 'DMs', + unreadToggle: 'Unreads', }, reportDetailsPage: { inWorkspace: (policyName: string) => `在 ${policyName} 中`, diff --git a/src/libs/SidebarUtils.ts b/src/libs/SidebarUtils.ts index 623caf8ad8d8..2bec6d18432c 100644 --- a/src/libs/SidebarUtils.ts +++ b/src/libs/SidebarUtils.ts @@ -1412,29 +1412,34 @@ function filterReportsForInboxTab( reportIDs: string[], reportsToDisplay: ReportsToDisplayInLHN, activeTab: ValueOf, - reportsDrafts?: Record, + showUnreadOnly: boolean, reportNameValuePairs?: OnyxCollection, ): string[] { - if (activeTab === CONST.INBOX_TAB.ALL) { - return reportIDs; - } - return reportIDs.filter((reportID) => { const report = reportsToDisplay[`${ONYXKEYS.COLLECTION.REPORT}${reportID}`]; if (!report) { return false; } - switch (activeTab) { - case CONST.INBOX_TAB.UNREADS: { - const isReportArchived = isArchivedReport(reportNameValuePairs?.[`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${reportID}`]); - const isReportUnread = isUnread(report, undefined, isReportArchived) && getReportNotificationPreference(report) !== CONST.REPORT.NOTIFICATION_PREFERENCE.MUTE; - const hasDraft = !!reportsDrafts?.[reportID]; - return isReportUnread || !!report.isPinned || !!report.requiresAttention || !!report.hasErrorsOtherThanFailedReceipt || hasDraft; + // Apply unread filter when the toggle is active + if (showUnreadOnly) { + const isReportArchived = isArchivedReport(reportNameValuePairs?.[`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${reportID}`]); + const isReportUnread = isUnread(report, undefined, isReportArchived) && getReportNotificationPreference(report) !== CONST.REPORT.NOTIFICATION_PREFERENCE.MUTE; + if (!isReportUnread && !report.isPinned && !report.requiresAttention && !report.hasErrorsOtherThanFailedReceipt) { + return false; } + } + + if (activeTab === CONST.INBOX_TAB.ALL) { + return true; + } + + switch (activeTab) { + case CONST.INBOX_TAB.TODO: + return !!report.requiresAttention || !!report.hasErrorsOtherThanFailedReceipt; case CONST.INBOX_TAB.EXPENSES: return isExpenseReport(report) || isIOUReport(report) || isInvoiceReport(report) || isPolicyExpenseChat(report); - case CONST.INBOX_TAB.DIRECT_MESSAGES: + case CONST.INBOX_TAB.DMS: return isDM(report) || isSelfDM(report) || isGroupChatUtil(report); default: return true; diff --git a/src/libs/actions/User.ts b/src/libs/actions/User.ts index 138d02d821d5..975dfeaafc19 100644 --- a/src/libs/actions/User.ts +++ b/src/libs/actions/User.ts @@ -953,6 +953,10 @@ function setInboxTab(tab: ValueOf) { Onyx.merge(ONYXKEYS.NVP_INBOX_TAB, tab); } +function setInboxUnreadFilter(enabled: boolean) { + Onyx.merge(ONYXKEYS.NVP_INBOX_UNREAD_FILTER, enabled); +} + function setShouldUseStagingServer(shouldUseStagingServer: boolean) { if (CONFIG.IS_HYBRID_APP) { HybridAppModule.shouldUseStaging(shouldUseStagingServer); @@ -1878,6 +1882,7 @@ export { subscribeToUserEvents, updatePreferredSkinTone, setInboxTab, + setInboxUnreadFilter, setShouldUseStagingServer, togglePlatformMute, joinScreenShare, diff --git a/src/pages/inbox/sidebar/BaseSidebarScreen.tsx b/src/pages/inbox/sidebar/BaseSidebarScreen.tsx index fb4a748e7460..8f3bf3d553d8 100644 --- a/src/pages/inbox/sidebar/BaseSidebarScreen.tsx +++ b/src/pages/inbox/sidebar/BaseSidebarScreen.tsx @@ -15,6 +15,7 @@ import {isMobile} from '@libs/Browser'; import type {SkeletonSpanReasonAttributes} from '@libs/telemetry/useSkeletonSpan'; import ONYXKEYS from '@src/ONYXKEYS'; import InboxTabSelector from './InboxTabSelector'; +import InboxUnreadToggle from './InboxUnreadToggle'; import SidebarLinksData from './SidebarLinksData'; // Once the app finishes loading for the first time, we never show the skeleton again @@ -59,7 +60,9 @@ function BaseSidebarScreen() { breadcrumbLabel={translate('common.inbox')} shouldDisplaySearch={shouldUseNarrowLayout} shouldDisplayHelpButton={shouldUseNarrowLayout} - /> + > + {!shouldShowSkeleton && } + {!shouldShowSkeleton && } {shouldShowSkeleton ? ( diff --git a/src/pages/inbox/sidebar/InboxTabSelector.tsx b/src/pages/inbox/sidebar/InboxTabSelector.tsx index d8e75506b891..0cc87b328b3e 100644 --- a/src/pages/inbox/sidebar/InboxTabSelector.tsx +++ b/src/pages/inbox/sidebar/InboxTabSelector.tsx @@ -13,9 +13,9 @@ function InboxTabSelector() { const tabs: TabSelectorBaseItem[] = [ {key: CONST.INBOX_TAB.ALL, title: translate('inboxTabs.all')}, - {key: CONST.INBOX_TAB.UNREADS, title: translate('inboxTabs.unreads')}, + {key: CONST.INBOX_TAB.TODO, title: translate('inboxTabs.todo')}, {key: CONST.INBOX_TAB.EXPENSES, title: translate('inboxTabs.expenses')}, - {key: CONST.INBOX_TAB.DIRECT_MESSAGES, title: translate('inboxTabs.directMessages')}, + {key: CONST.INBOX_TAB.DMS, title: translate('inboxTabs.dms')}, ]; return ( diff --git a/src/pages/inbox/sidebar/InboxUnreadToggle.tsx b/src/pages/inbox/sidebar/InboxUnreadToggle.tsx new file mode 100644 index 000000000000..e5d38653b2a8 --- /dev/null +++ b/src/pages/inbox/sidebar/InboxUnreadToggle.tsx @@ -0,0 +1,30 @@ +import React from 'react'; +import {View} from 'react-native'; +import Switch from '@components/Switch'; +import Text from '@components/Text'; +import useLocalize from '@hooks/useLocalize'; +import {useSidebarOrderedReportsActions, useSidebarOrderedReportsState} from '@hooks/useSidebarOrderedReports'; +import useThemeStyles from '@hooks/useThemeStyles'; + +function InboxUnreadToggle() { + const styles = useThemeStyles(); + const {translate} = useLocalize(); + const {showUnreadOnly} = useSidebarOrderedReportsState(); + const {toggleUnreadFilter} = useSidebarOrderedReportsActions(); + + return ( + + {translate('inboxTabs.unreadToggle')} + toggleUnreadFilter()} + accessibilityLabel={translate('inboxTabs.unreadToggle')} + size="small" + /> + + ); +} + +InboxUnreadToggle.displayName = 'InboxUnreadToggle'; + +export default InboxUnreadToggle; diff --git a/src/styles/index.ts b/src/styles/index.ts index eae83119cc2f..172579d142fb 100644 --- a/src/styles/index.ts +++ b/src/styles/index.ts @@ -3039,6 +3039,13 @@ const staticStyles = (theme: ThemeColors) => padding: 15, }, + switchTrackSmall: { + width: 36, + height: 20, + borderRadius: 10, + padding: 10, + }, + switchThumb: { width: 22, height: 22, @@ -3050,6 +3057,13 @@ const staticStyles = (theme: ThemeColors) => backgroundColor: theme.appBG, }, + switchThumbSmall: { + width: 14, + height: 14, + borderRadius: 7, + left: 3, + }, + radioButtonContainer: { backgroundColor: theme.componentBG, borderRadius: 14, From 8634be9aef25b9b6c02ab26feb9fd67635c86a6d Mon Sep 17 00:00:00 2001 From: Shawn Borton Date: Fri, 17 Apr 2026 15:56:42 +0200 Subject: [PATCH 011/435] Change 'To do' tab label to 'To-do' Co-Authored-By: Claude Opus 4.6 (1M context) --- src/languages/de.ts | 2 +- src/languages/en.ts | 2 +- src/languages/es.ts | 2 +- src/languages/fr.ts | 2 +- src/languages/it.ts | 2 +- src/languages/ja.ts | 2 +- src/languages/nl.ts | 2 +- src/languages/pl.ts | 2 +- src/languages/pt-BR.ts | 2 +- src/languages/zh-hans.ts | 2 +- 10 files changed, 10 insertions(+), 10 deletions(-) diff --git a/src/languages/de.ts b/src/languages/de.ts index befcb46025c3..f524383c939a 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -2788,7 +2788,7 @@ ${amount} für ${merchant} – ${date}`, }, inboxTabs: { all: 'All', - todo: 'To do', + todo: 'To-do', expenses: 'Expenses', dms: 'DMs', unreadToggle: 'Unreads', diff --git a/src/languages/en.ts b/src/languages/en.ts index 9be6ecbc314c..65997ac79bc6 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -2842,7 +2842,7 @@ const translations = { }, inboxTabs: { all: 'All', - todo: 'To do', + todo: 'To-do', expenses: 'Expenses', dms: 'DMs', unreadToggle: 'Unreads', diff --git a/src/languages/es.ts b/src/languages/es.ts index 741f20fc7dfb..a0e6fcbbe9d2 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -2659,7 +2659,7 @@ ${amount} para ${merchant} - ${date}`, }, inboxTabs: { all: 'All', - todo: 'To do', + todo: 'To-do', expenses: 'Expenses', dms: 'DMs', unreadToggle: 'Unreads', diff --git a/src/languages/fr.ts b/src/languages/fr.ts index d90ee798f375..c7eabf81d038 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -2794,7 +2794,7 @@ ${amount} pour ${merchant} - ${date}`, }, inboxTabs: { all: 'All', - todo: 'To do', + todo: 'To-do', expenses: 'Expenses', dms: 'DMs', unreadToggle: 'Unreads', diff --git a/src/languages/it.ts b/src/languages/it.ts index dd14322c29c2..b1f599039dc7 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -2783,7 +2783,7 @@ ${amount} per ${merchant} - ${date}`, }, inboxTabs: { all: 'All', - todo: 'To do', + todo: 'To-do', expenses: 'Expenses', dms: 'DMs', unreadToggle: 'Unreads', diff --git a/src/languages/ja.ts b/src/languages/ja.ts index 1600d5034687..abf3fc73df15 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -2756,7 +2756,7 @@ ${date} の ${merchant} への ${amount}`, }, inboxTabs: { all: 'All', - todo: 'To do', + todo: 'To-do', expenses: 'Expenses', dms: 'DMs', unreadToggle: 'Unreads', diff --git a/src/languages/nl.ts b/src/languages/nl.ts index 9617a0d09edb..4cc5e5d067f1 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -2781,7 +2781,7 @@ ${amount} voor ${merchant} - ${date}`, }, inboxTabs: { all: 'All', - todo: 'To do', + todo: 'To-do', expenses: 'Expenses', dms: 'DMs', unreadToggle: 'Unreads', diff --git a/src/languages/pl.ts b/src/languages/pl.ts index 4950507eb6d8..7cc40fa1f87c 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -2774,7 +2774,7 @@ ${amount} dla ${merchant} - ${date}`, }, inboxTabs: { all: 'All', - todo: 'To do', + todo: 'To-do', expenses: 'Expenses', dms: 'DMs', unreadToggle: 'Unreads', diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index a15d7cc509c4..912f0c2f827d 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -2774,7 +2774,7 @@ ${amount} para ${merchant} - ${date}`, }, inboxTabs: { all: 'All', - todo: 'To do', + todo: 'To-do', expenses: 'Expenses', dms: 'DMs', unreadToggle: 'Unreads', diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index 3db3f03dda5c..2eba054e6446 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -2706,7 +2706,7 @@ ${amount},商户:${merchant} - 日期:${date}`, }, inboxTabs: { all: 'All', - todo: 'To do', + todo: 'To-do', expenses: 'Expenses', dms: 'DMs', unreadToggle: 'Unreads', From e2ebe4607b02abdaeac731b9e04f48017d3fe441 Mon Sep 17 00:00:00 2001 From: Shawn Borton Date: Tue, 21 Apr 2026 18:14:56 +0200 Subject: [PATCH 012/435] Replace Priority Mode with Inbox Tab Filters Simplify inbox filtering to four straightforward tabs: All, Unread, Expenses, and DMs. Remove the separate Unreads toggle and To-do tab in favor of a clean Unread tab that shows only unread and non-muted chats. Pinned/GBR/RBR items show only if they qualify for the active tab filter. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/CONST/index.ts | 2 +- src/ONYXKEYS.ts | 4 --- src/hooks/useSidebarOrderedReports.tsx | 23 ++++---------- src/languages/de.ts | 3 +- src/languages/en.ts | 3 +- src/languages/es.ts | 3 +- src/languages/fr.ts | 3 +- src/languages/it.ts | 3 +- src/languages/ja.ts | 3 +- src/languages/nl.ts | 3 +- src/languages/pl.ts | 3 +- src/languages/pt-BR.ts | 3 +- src/languages/zh-hans.ts | 3 +- src/libs/SidebarUtils.ts | 24 +++++---------- src/libs/actions/User.ts | 5 ---- src/pages/inbox/sidebar/BaseSidebarScreen.tsx | 5 +--- src/pages/inbox/sidebar/InboxTabSelector.tsx | 2 +- src/pages/inbox/sidebar/InboxUnreadToggle.tsx | 30 ------------------- 18 files changed, 26 insertions(+), 99 deletions(-) delete mode 100644 src/pages/inbox/sidebar/InboxUnreadToggle.tsx diff --git a/src/CONST/index.ts b/src/CONST/index.ts index be0e4fd88728..6bf0ab68c2c2 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -2085,7 +2085,7 @@ const CONST = { }, INBOX_TAB: { ALL: 'all', - TODO: 'todo', + UNREAD: 'unread', EXPENSES: 'expenses', DMS: 'dms', }, diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts index 3dce345ee6e7..b7da30c7c155 100755 --- a/src/ONYXKEYS.ts +++ b/src/ONYXKEYS.ts @@ -162,9 +162,6 @@ const ONYXKEYS = { /** Contains the user preference for the active inbox tab filter */ NVP_INBOX_TAB: 'nvp_inboxTab', - /** Whether to show only unread chats in the inbox */ - NVP_INBOX_UNREAD_FILTER: 'nvp_inboxUnreadFilter', - /** Contains the users's block expiration (if they have one) */ NVP_BLOCKED_FROM_CONCIERGE: 'nvp_private_blockedFromConcierge', @@ -1357,7 +1354,6 @@ type OnyxValuesMapping = { [ONYXKEYS.BETA_CONFIGURATION]: OnyxTypes.BetaConfiguration; [ONYXKEYS.NVP_MUTED_PLATFORMS]: Partial>; [ONYXKEYS.NVP_INBOX_TAB]: ValueOf; - [ONYXKEYS.NVP_INBOX_UNREAD_FILTER]: boolean; [ONYXKEYS.NVP_BLOCKED_FROM_CONCIERGE]: OnyxTypes.BlockedFromConcierge; [ONYXKEYS.QUEUE_FLUSHED_DATA]: AnyOnyxUpdate[]; [ONYXKEYS.TRANSACTIONS_PENDING_3DS_REVIEW]: OnyxTypes.TransactionsPending3DSReview; diff --git a/src/hooks/useSidebarOrderedReports.tsx b/src/hooks/useSidebarOrderedReports.tsx index c54abee7d3b8..f0164fadc729 100644 --- a/src/hooks/useSidebarOrderedReports.tsx +++ b/src/hooks/useSidebarOrderedReports.tsx @@ -2,7 +2,7 @@ import {deepEqual} from 'fast-equals'; import React, {createContext, useCallback, useContext, useEffect, useMemo, useRef, useState} from 'react'; import type {OnyxEntry} from 'react-native-onyx'; import type {ValueOf} from 'type-fest'; -import {setInboxTab, setInboxUnreadFilter} from '@libs/actions/User'; +import {setInboxTab} from '@libs/actions/User'; import Log from '@libs/Log'; import {getTransactionThreadReportID} from '@libs/MergeTransactionUtils'; import {isOneTransactionReport} from '@libs/ReportUtils'; @@ -36,13 +36,11 @@ type SidebarOrderedReportsStateContextValue = { currentReportID: string | undefined; chatTabBrickRoad: BrickRoad; activeTab: ValueOf; - showUnreadOnly: boolean; }; type SidebarOrderedReportsActionsContextValue = { clearLHNCache: () => void; setActiveTab: (tab: ValueOf) => void; - toggleUnreadFilter: () => void; }; type ReportsToDisplayInLHN = Record; @@ -53,13 +51,11 @@ const SidebarOrderedReportsStateContext = createContext({ clearLHNCache: () => {}, setActiveTab: () => {}, - toggleUnreadFilter: () => {}, }); const policyMapper = (policy: OnyxEntry): PartialPolicyForSidebar => @@ -85,8 +81,6 @@ function SidebarOrderedReportsContextProvider({ const {localeCompare} = useLocalize(); const [inboxTab = CONST.INBOX_TAB.ALL] = useOnyx(ONYXKEYS.NVP_INBOX_TAB); const activeTab = inboxTab ?? CONST.INBOX_TAB.ALL; - const [inboxUnreadFilter = false] = useOnyx(ONYXKEYS.NVP_INBOX_UNREAD_FILTER); - const showUnreadOnly = inboxUnreadFilter ?? false; const [chatReports, {sourceValue: reportUpdates}] = useOnyx(ONYXKEYS.COLLECTION.REPORT); const [policies, {sourceValue: policiesUpdates}] = useMappedPolicies(policyMapper); const [transactions, {sourceValue: transactionsUpdates}] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION); @@ -271,8 +265,8 @@ function SidebarOrderedReportsContextProvider({ const orderedReportIDs = useMemo(() => getOrderedReportIDs(), [getOrderedReportIDs]); const filteredReportIDs = useMemo( - () => SidebarUtils.filterReportsForInboxTab(orderedReportIDs, reportsToDisplayInLHN, activeTab, showUnreadOnly, reportNameValuePairs), - [orderedReportIDs, reportsToDisplayInLHN, activeTab, showUnreadOnly, reportNameValuePairs], + () => SidebarUtils.filterReportsForInboxTab(orderedReportIDs, reportsToDisplayInLHN, activeTab, reportNameValuePairs), + [orderedReportIDs, reportsToDisplayInLHN, activeTab, reportNameValuePairs], ); // Get the actual reports based on the filtered IDs @@ -298,10 +292,6 @@ function SidebarOrderedReportsContextProvider({ setInboxTab(tab); }, []); - const toggleUnreadFilter = useCallback(() => { - setInboxUnreadFilter(!showUnreadOnly); - }, [showUnreadOnly]); - const stateValue: SidebarOrderedReportsStateContextValue = useMemo(() => { // We need to make sure the current report is in the list of reports, but we do not want // to have to re-generate the list every time the currentReportID changes. To do that @@ -320,7 +310,7 @@ function SidebarOrderedReportsContextProvider({ filteredReportIDs.indexOf(derivedCurrentReportID) === -1 ) { const updatedReportIDs = getOrderedReportIDs(); - const updatedFilteredIDs = SidebarUtils.filterReportsForInboxTab(updatedReportIDs, reportsToDisplayInLHN, activeTab, showUnreadOnly, reportNameValuePairs); + const updatedFilteredIDs = SidebarUtils.filterReportsForInboxTab(updatedReportIDs, reportsToDisplayInLHN, activeTab, reportNameValuePairs); const updatedReports = getOrderedReports(updatedFilteredIDs); return { orderedReports: updatedReports, @@ -328,7 +318,6 @@ function SidebarOrderedReportsContextProvider({ currentReportID: derivedCurrentReportID, chatTabBrickRoad: getChatTabBrickRoad(updatedReportIDs, reportAttributes), activeTab, - showUnreadOnly, }; } @@ -338,7 +327,6 @@ function SidebarOrderedReportsContextProvider({ currentReportID: derivedCurrentReportID, chatTabBrickRoad: getChatTabBrickRoad(orderedReportIDs, reportAttributes), activeTab, - showUnreadOnly, }; }, [ getOrderedReportIDs, @@ -350,12 +338,11 @@ function SidebarOrderedReportsContextProvider({ orderedReports, reportAttributes, activeTab, - showUnreadOnly, reportsToDisplayInLHN, reportNameValuePairs, ]); - const actionsValue: SidebarOrderedReportsActionsContextValue = useMemo(() => ({clearLHNCache, setActiveTab, toggleUnreadFilter}), [clearLHNCache, setActiveTab, toggleUnreadFilter]); + const actionsValue: SidebarOrderedReportsActionsContextValue = useMemo(() => ({clearLHNCache, setActiveTab}), [clearLHNCache, setActiveTab]); const currentDeps = { activeTab, diff --git a/src/languages/de.ts b/src/languages/de.ts index f524383c939a..80b0180e4926 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -2788,10 +2788,9 @@ ${amount} für ${merchant} – ${date}`, }, inboxTabs: { all: 'All', - todo: 'To-do', + unread: 'Unread', expenses: 'Expenses', dms: 'DMs', - unreadToggle: 'Unreads', }, reportDetailsPage: { inWorkspace: (policyName: string) => `in ${policyName}`, diff --git a/src/languages/en.ts b/src/languages/en.ts index 65997ac79bc6..8e2103eff286 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -2842,10 +2842,9 @@ const translations = { }, inboxTabs: { all: 'All', - todo: 'To-do', + unread: 'Unread', expenses: 'Expenses', dms: 'DMs', - unreadToggle: 'Unreads', }, reportDetailsPage: { inWorkspace: (policyName: string) => `in ${policyName}`, diff --git a/src/languages/es.ts b/src/languages/es.ts index a0e6fcbbe9d2..7a8425dd7fec 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -2659,10 +2659,9 @@ ${amount} para ${merchant} - ${date}`, }, inboxTabs: { all: 'All', - todo: 'To-do', + unread: 'Unread', expenses: 'Expenses', dms: 'DMs', - unreadToggle: 'Unreads', }, reportDetailsPage: { inWorkspace: (policyName) => `en ${policyName}`, diff --git a/src/languages/fr.ts b/src/languages/fr.ts index c7eabf81d038..8633626d7080 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -2794,10 +2794,9 @@ ${amount} pour ${merchant} - ${date}`, }, inboxTabs: { all: 'All', - todo: 'To-do', + unread: 'Unread', expenses: 'Expenses', dms: 'DMs', - unreadToggle: 'Unreads', }, reportDetailsPage: { inWorkspace: (policyName: string) => `dans ${policyName}`, diff --git a/src/languages/it.ts b/src/languages/it.ts index b1f599039dc7..cd28d7c31dad 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -2783,10 +2783,9 @@ ${amount} per ${merchant} - ${date}`, }, inboxTabs: { all: 'All', - todo: 'To-do', + unread: 'Unread', expenses: 'Expenses', dms: 'DMs', - unreadToggle: 'Unreads', }, reportDetailsPage: { inWorkspace: (policyName: string) => `in ${policyName}`, diff --git a/src/languages/ja.ts b/src/languages/ja.ts index abf3fc73df15..0da264509b32 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -2756,10 +2756,9 @@ ${date} の ${merchant} への ${amount}`, }, inboxTabs: { all: 'All', - todo: 'To-do', + unread: 'Unread', expenses: 'Expenses', dms: 'DMs', - unreadToggle: 'Unreads', }, reportDetailsPage: { inWorkspace: (policyName: string) => `${policyName} 内`, diff --git a/src/languages/nl.ts b/src/languages/nl.ts index 4cc5e5d067f1..ead8c12235e4 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -2781,10 +2781,9 @@ ${amount} voor ${merchant} - ${date}`, }, inboxTabs: { all: 'All', - todo: 'To-do', + unread: 'Unread', expenses: 'Expenses', dms: 'DMs', - unreadToggle: 'Unreads', }, reportDetailsPage: { inWorkspace: (policyName: string) => `in ${policyName}`, diff --git a/src/languages/pl.ts b/src/languages/pl.ts index 7cc40fa1f87c..0a08aad6fd97 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -2774,10 +2774,9 @@ ${amount} dla ${merchant} - ${date}`, }, inboxTabs: { all: 'All', - todo: 'To-do', + unread: 'Unread', expenses: 'Expenses', dms: 'DMs', - unreadToggle: 'Unreads', }, reportDetailsPage: { inWorkspace: (policyName: string) => `w ${policyName}`, diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index 912f0c2f827d..e2b81c2f510e 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -2774,10 +2774,9 @@ ${amount} para ${merchant} - ${date}`, }, inboxTabs: { all: 'All', - todo: 'To-do', + unread: 'Unread', expenses: 'Expenses', dms: 'DMs', - unreadToggle: 'Unreads', }, reportDetailsPage: { inWorkspace: (policyName: string) => `em ${policyName}`, diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index 2eba054e6446..cfbd7bf3d21d 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -2706,10 +2706,9 @@ ${amount},商户:${merchant} - 日期:${date}`, }, inboxTabs: { all: 'All', - todo: 'To-do', + unread: 'Unread', expenses: 'Expenses', dms: 'DMs', - unreadToggle: 'Unreads', }, reportDetailsPage: { inWorkspace: (policyName: string) => `在 ${policyName} 中`, diff --git a/src/libs/SidebarUtils.ts b/src/libs/SidebarUtils.ts index 2bec6d18432c..0cf4dd3b86aa 100644 --- a/src/libs/SidebarUtils.ts +++ b/src/libs/SidebarUtils.ts @@ -1412,31 +1412,23 @@ function filterReportsForInboxTab( reportIDs: string[], reportsToDisplay: ReportsToDisplayInLHN, activeTab: ValueOf, - showUnreadOnly: boolean, reportNameValuePairs?: OnyxCollection, ): string[] { + if (activeTab === CONST.INBOX_TAB.ALL) { + return reportIDs; + } + return reportIDs.filter((reportID) => { const report = reportsToDisplay[`${ONYXKEYS.COLLECTION.REPORT}${reportID}`]; if (!report) { return false; } - // Apply unread filter when the toggle is active - if (showUnreadOnly) { - const isReportArchived = isArchivedReport(reportNameValuePairs?.[`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${reportID}`]); - const isReportUnread = isUnread(report, undefined, isReportArchived) && getReportNotificationPreference(report) !== CONST.REPORT.NOTIFICATION_PREFERENCE.MUTE; - if (!isReportUnread && !report.isPinned && !report.requiresAttention && !report.hasErrorsOtherThanFailedReceipt) { - return false; - } - } - - if (activeTab === CONST.INBOX_TAB.ALL) { - return true; - } - switch (activeTab) { - case CONST.INBOX_TAB.TODO: - return !!report.requiresAttention || !!report.hasErrorsOtherThanFailedReceipt; + case CONST.INBOX_TAB.UNREAD: { + const isReportArchived = isArchivedReport(reportNameValuePairs?.[`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${reportID}`]); + return isUnread(report, undefined, isReportArchived) && getReportNotificationPreference(report) !== CONST.REPORT.NOTIFICATION_PREFERENCE.MUTE; + } case CONST.INBOX_TAB.EXPENSES: return isExpenseReport(report) || isIOUReport(report) || isInvoiceReport(report) || isPolicyExpenseChat(report); case CONST.INBOX_TAB.DMS: diff --git a/src/libs/actions/User.ts b/src/libs/actions/User.ts index 975dfeaafc19..138d02d821d5 100644 --- a/src/libs/actions/User.ts +++ b/src/libs/actions/User.ts @@ -953,10 +953,6 @@ function setInboxTab(tab: ValueOf) { Onyx.merge(ONYXKEYS.NVP_INBOX_TAB, tab); } -function setInboxUnreadFilter(enabled: boolean) { - Onyx.merge(ONYXKEYS.NVP_INBOX_UNREAD_FILTER, enabled); -} - function setShouldUseStagingServer(shouldUseStagingServer: boolean) { if (CONFIG.IS_HYBRID_APP) { HybridAppModule.shouldUseStaging(shouldUseStagingServer); @@ -1882,7 +1878,6 @@ export { subscribeToUserEvents, updatePreferredSkinTone, setInboxTab, - setInboxUnreadFilter, setShouldUseStagingServer, togglePlatformMute, joinScreenShare, diff --git a/src/pages/inbox/sidebar/BaseSidebarScreen.tsx b/src/pages/inbox/sidebar/BaseSidebarScreen.tsx index 8f3bf3d553d8..fb4a748e7460 100644 --- a/src/pages/inbox/sidebar/BaseSidebarScreen.tsx +++ b/src/pages/inbox/sidebar/BaseSidebarScreen.tsx @@ -15,7 +15,6 @@ import {isMobile} from '@libs/Browser'; import type {SkeletonSpanReasonAttributes} from '@libs/telemetry/useSkeletonSpan'; import ONYXKEYS from '@src/ONYXKEYS'; import InboxTabSelector from './InboxTabSelector'; -import InboxUnreadToggle from './InboxUnreadToggle'; import SidebarLinksData from './SidebarLinksData'; // Once the app finishes loading for the first time, we never show the skeleton again @@ -60,9 +59,7 @@ function BaseSidebarScreen() { breadcrumbLabel={translate('common.inbox')} shouldDisplaySearch={shouldUseNarrowLayout} shouldDisplayHelpButton={shouldUseNarrowLayout} - > - {!shouldShowSkeleton && } - + /> {!shouldShowSkeleton && } {shouldShowSkeleton ? ( diff --git a/src/pages/inbox/sidebar/InboxTabSelector.tsx b/src/pages/inbox/sidebar/InboxTabSelector.tsx index 0cc87b328b3e..4de90b6aa6e5 100644 --- a/src/pages/inbox/sidebar/InboxTabSelector.tsx +++ b/src/pages/inbox/sidebar/InboxTabSelector.tsx @@ -13,7 +13,7 @@ function InboxTabSelector() { const tabs: TabSelectorBaseItem[] = [ {key: CONST.INBOX_TAB.ALL, title: translate('inboxTabs.all')}, - {key: CONST.INBOX_TAB.TODO, title: translate('inboxTabs.todo')}, + {key: CONST.INBOX_TAB.UNREAD, title: translate('inboxTabs.unread')}, {key: CONST.INBOX_TAB.EXPENSES, title: translate('inboxTabs.expenses')}, {key: CONST.INBOX_TAB.DMS, title: translate('inboxTabs.dms')}, ]; diff --git a/src/pages/inbox/sidebar/InboxUnreadToggle.tsx b/src/pages/inbox/sidebar/InboxUnreadToggle.tsx deleted file mode 100644 index e5d38653b2a8..000000000000 --- a/src/pages/inbox/sidebar/InboxUnreadToggle.tsx +++ /dev/null @@ -1,30 +0,0 @@ -import React from 'react'; -import {View} from 'react-native'; -import Switch from '@components/Switch'; -import Text from '@components/Text'; -import useLocalize from '@hooks/useLocalize'; -import {useSidebarOrderedReportsActions, useSidebarOrderedReportsState} from '@hooks/useSidebarOrderedReports'; -import useThemeStyles from '@hooks/useThemeStyles'; - -function InboxUnreadToggle() { - const styles = useThemeStyles(); - const {translate} = useLocalize(); - const {showUnreadOnly} = useSidebarOrderedReportsState(); - const {toggleUnreadFilter} = useSidebarOrderedReportsActions(); - - return ( - - {translate('inboxTabs.unreadToggle')} - toggleUnreadFilter()} - accessibilityLabel={translate('inboxTabs.unreadToggle')} - size="small" - /> - - ); -} - -InboxUnreadToggle.displayName = 'InboxUnreadToggle'; - -export default InboxUnreadToggle; From 8b34f0890860298d826b5a758bfaf371243aafbe Mon Sep 17 00:00:00 2001 From: Rayane <77965000+rayane-d@users.noreply.github.com> Date: Wed, 6 May 2026 19:40:59 +0100 Subject: [PATCH 013/435] Add support for category tax rate update changelog report action - Introduced new constant for category tax rate updates reprt action in CONST. - Added English copy for updated category tax rate. - Implemented message retrieval for category tax rate changes in ReportActionsUtils. - Updated OriginalMessage type to include new tax rate details fields for categories. --- src/CONST/index.ts | 1 + src/languages/en.ts | 17 +++++++++++++++++ src/libs/ReportActionsUtils.ts | 18 ++++++++++++++++++ src/types/onyx/OriginalMessage.ts | 10 ++++++++-- 4 files changed, 44 insertions(+), 2 deletions(-) diff --git a/src/CONST/index.ts b/src/CONST/index.ts index d41a326a1db3..f9852a20e0d0 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -1566,6 +1566,7 @@ const CONST = { UPDATE_AUTO_REPORTING_FREQUENCY: 'POLICYCHANGELOG_UPDATE_AUTOREPORTING_FREQUENCY', UPDATE_BUDGET: 'POLICYCHANGELOG_UPDATE_BUDGET', UPDATE_CATEGORY: 'POLICYCHANGELOG_UPDATE_CATEGORY', + UPDATE_CATEGORY_TAX_RATE: 'POLICYCHANGELOG_UPDATE_CATEGORY_TAX_RATE', UPDATE_CATEGORIES: 'POLICYCHANGELOG_UPDATE_CATEGORIES', UPDATE_CURRENCY: 'POLICYCHANGELOG_UPDATE_CURRENCY', UPDATE_CUSTOM_UNIT: 'POLICYCHANGELOG_UPDATE_CUSTOM_UNIT', diff --git a/src/languages/en.ts b/src/languages/en.ts index db0c869adeb2..1b0a09b9e6e1 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -7453,6 +7453,23 @@ const translations = { updatedAutoPayApprovedReportsLimit: ({oldLimit, newLimit}: {oldLimit: string; newLimit: string}) => `changed the auto-pay approved reports threshold to "${newLimit}" (previously "${oldLimit}")`, removedAutoPayApprovedReportsLimit: 'removed the auto-pay approved reports threshold', + updatedCategoryTaxRate: ({ + categoryName, + oldTaxName, + oldTaxPercentage, + newTaxName, + newTaxPercentage, + }: { + categoryName: string; + oldTaxName: string; + oldTaxPercentage: string; + newTaxName: string; + newTaxPercentage: string; + }) => { + const oldTax = oldTaxPercentage ? `${oldTaxName} (${oldTaxPercentage})` : oldTaxName; + const newTax = newTaxPercentage ? `${newTaxName} (${newTaxPercentage})` : newTaxName; + return `changed the "${categoryName}" category default tax rate to "${newTax}" (previously "${oldTax}")`; + }, changedDefaultApprover: ({newApprover, previousApprover}: {newApprover: string; previousApprover?: string}) => previousApprover ? `changed the default approver to ${newApprover} (previously ${previousApprover})` : `changed the default approver to ${newApprover}`, changedSubmitsToApprover: ({ diff --git a/src/libs/ReportActionsUtils.ts b/src/libs/ReportActionsUtils.ts index feb90e8ec082..8b466073746f 100644 --- a/src/libs/ReportActionsUtils.ts +++ b/src/libs/ReportActionsUtils.ts @@ -3452,6 +3452,23 @@ function getAutoPayApprovedReportsEnabledMessage(translate: LocalizedTranslate, return translate('workspaceActions.updatedAutoPayApprovedReports', {enabled: !!enabled}); } +function getCategoryTaxRateMessage(translate: LocalizedTranslate, action: ReportAction): string { + const {categoryName, oldTaxName, oldTaxPercentage, newTaxName, newTaxPercentage} = + getOriginalMessage(action as ReportAction) ?? {}; + + if (!categoryName || !newTaxName) { + return getReportActionText(action); + } + + return translate('workspaceActions.updatedCategoryTaxRate', { + categoryName, + oldTaxName: oldTaxName ?? '', + oldTaxPercentage: oldTaxPercentage ?? '', + newTaxName, + newTaxPercentage: newTaxPercentage ?? '', + }); +} + function getAutoReimbursementMessage(translate: LocalizedTranslate, action: ReportAction): string { const {oldLimit, newLimit, currency} = getOriginalMessage(action as ReportAction) ?? {}; @@ -4742,6 +4759,7 @@ export { getRequireCompanyCardsEnabledMessage, getAutoPayApprovedReportsEnabledMessage, getAutoReimbursementMessage, + getCategoryTaxRateMessage, formatAddressToString, getCompanyAddressUpdateMessage, getDefaultApproverUpdateMessage, diff --git a/src/types/onyx/OriginalMessage.ts b/src/types/onyx/OriginalMessage.ts index a399136ba2b9..69a66c9aca35 100644 --- a/src/types/onyx/OriginalMessage.ts +++ b/src/types/onyx/OriginalMessage.ts @@ -566,12 +566,18 @@ type OriginalMessagePolicyChangeLog = { /** Custom unit name */ rateName?: string; - /** Tax percentage of the new tax rate linked to distance rate */ + /** Tax percentage of the new tax rate linked to distance rate or category */ newTaxPercentage?: string; - /** Tax percentage of the old tax rate linked to distance rate */ + /** Tax percentage of the old tax rate linked to distance rate or category */ oldTaxPercentage?: string; + /** Name of the new tax rate (without percentage) for category default tax rate */ + newTaxName?: string; + + /** Name of the old tax rate (without percentage) for category default tax rate */ + oldTaxName?: string; + /** Added/Updated tag name */ tagName?: string; From 893307f8cbbecf43f14216c5068319a3934a0f99 Mon Sep 17 00:00:00 2001 From: Rayane <77965000+rayane-d@users.noreply.github.com> Date: Wed, 6 May 2026 19:41:52 +0100 Subject: [PATCH 014/435] Integrated `getCategoryTaxRateMessage` into various utility files to handle category default tax rate updates report action. --- src/libs/OptionsListUtils/index.ts | 4 ++++ src/libs/ReportNameUtils.ts | 4 ++++ src/libs/SidebarUtils.ts | 3 +++ src/pages/inbox/report/ContextMenu/ContextMenuActions.tsx | 3 +++ .../inbox/report/actionContents/PolicyChangeLogContent.tsx | 2 ++ 5 files changed, 16 insertions(+) diff --git a/src/libs/OptionsListUtils/index.ts b/src/libs/OptionsListUtils/index.ts index 187ebbcef864..dc9af89f1105 100644 --- a/src/libs/OptionsListUtils/index.ts +++ b/src/libs/OptionsListUtils/index.ts @@ -40,6 +40,7 @@ import { getAssignedCompanyCardMessage, getAutoPayApprovedReportsEnabledMessage, getAutoReimbursementMessage, + getCategoryTaxRateMessage, getChangedApproverActionMessage, getCombinedReportActions, getCurrencyDefaultTaxUpdateMessage, @@ -889,6 +890,9 @@ function getLastMessageTextForReport({ if (isActionOfType(lastReportAction, CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_AUTO_REIMBURSEMENT)) { lastMessageTextFromReport = getAutoReimbursementMessage(translate, lastReportAction); } + if (isActionOfType(lastReportAction, CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_CATEGORY_TAX_RATE)) { + lastMessageTextFromReport = getCategoryTaxRateMessage(translate, lastReportAction); + } if ( isActionOfType(lastReportAction, CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.ADD_TAX) || isActionOfType(lastReportAction, CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.DELETE_TAX) || diff --git a/src/libs/ReportNameUtils.ts b/src/libs/ReportNameUtils.ts index c06e0c2ab505..f9dc494c29b2 100644 --- a/src/libs/ReportNameUtils.ts +++ b/src/libs/ReportNameUtils.ts @@ -38,6 +38,7 @@ import { getAutoPayApprovedReportsEnabledMessage, getAutoReimbursementMessage, getCardIssuedMessage, + getCategoryTaxRateMessage, getChangedApproverActionMessage, getCompanyAddressUpdateMessage, getCompanyCardConnectionBrokenMessage, @@ -587,6 +588,9 @@ function computeReportNameBasedOnReportAction( if (isActionOfType(parentReportAction, CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_AUTO_REIMBURSEMENT)) { return getAutoReimbursementMessage(translate, parentReportAction); } + if (isActionOfType(parentReportAction, CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_CATEGORY_TAX_RATE)) { + return getCategoryTaxRateMessage(translate, parentReportAction); + } if (isActionOfType(parentReportAction, CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_DEFAULT_APPROVER)) { return getDefaultApproverUpdateMessage(translate, parentReportAction); } diff --git a/src/libs/SidebarUtils.ts b/src/libs/SidebarUtils.ts index b4068d56f90c..3cd6e9f536db 100644 --- a/src/libs/SidebarUtils.ts +++ b/src/libs/SidebarUtils.ts @@ -46,6 +46,7 @@ import { getAutoPayApprovedReportsEnabledMessage, getAutoReimbursementMessage, getCardIssuedMessage, + getCategoryTaxRateMessage, getChangedApproverActionMessage, getCompanyAddressUpdateMessage, getCompanyCardConnectionBrokenMessage, @@ -1083,6 +1084,8 @@ function getOptionData({ result.alternateText = getAutoPayApprovedReportsEnabledMessage(translate, lastAction); } else if (lastAction?.actionName === CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_AUTO_REIMBURSEMENT) { result.alternateText = getAutoReimbursementMessage(translate, lastAction); + } else if (lastAction?.actionName === CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_CATEGORY_TAX_RATE) { + result.alternateText = getCategoryTaxRateMessage(translate, lastAction); } else if (lastAction?.actionName === CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_DEFAULT_APPROVER) { result.alternateText = getDefaultApproverUpdateMessage(translate, lastAction); } else if (lastAction?.actionName === CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_SUBMITS_TO) { diff --git a/src/pages/inbox/report/ContextMenu/ContextMenuActions.tsx b/src/pages/inbox/report/ContextMenu/ContextMenuActions.tsx index ca78eb8814d1..683f85c24283 100644 --- a/src/pages/inbox/report/ContextMenu/ContextMenuActions.tsx +++ b/src/pages/inbox/report/ContextMenu/ContextMenuActions.tsx @@ -37,6 +37,7 @@ import { getAutoPayApprovedReportsEnabledMessage, getAutoReimbursementMessage, getCardIssuedMessage, + getCategoryTaxRateMessage, getChangedApproverActionMessage, getCompanyAddressUpdateMessage, getCompanyCardConnectionBrokenMessage, @@ -966,6 +967,8 @@ const ContextMenuActions: ContextMenuAction[] = [ Clipboard.setString(getRequireCompanyCardsEnabledMessage(translate, reportAction)); } else if (reportAction.actionName === CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_AUTO_REIMBURSEMENT) { Clipboard.setString(getAutoReimbursementMessage(translate, reportAction)); + } else if (reportAction.actionName === CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_CATEGORY_TAX_RATE) { + Clipboard.setString(getCategoryTaxRateMessage(translate, reportAction)); } else if (reportAction.actionName === CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_INVOICE_COMPANY_NAME) { Clipboard.setString(getInvoiceCompanyNameUpdateMessage(translate, reportAction)); } else if (reportAction.actionName === CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_INVOICE_COMPANY_WEBSITE) { diff --git a/src/pages/inbox/report/actionContents/PolicyChangeLogContent.tsx b/src/pages/inbox/report/actionContents/PolicyChangeLogContent.tsx index 63ac39058150..eaee6b9f6a37 100644 --- a/src/pages/inbox/report/actionContents/PolicyChangeLogContent.tsx +++ b/src/pages/inbox/report/actionContents/PolicyChangeLogContent.tsx @@ -13,6 +13,7 @@ import { getAssignedCompanyCardMessage, getAutoPayApprovedReportsEnabledMessage, getAutoReimbursementMessage, + getCategoryTaxRateMessage, getCompanyAddressUpdateMessage, getCurrencyDefaultTaxUpdateMessage, getCustomTaxNameUpdateMessage, @@ -143,6 +144,7 @@ const POLICY_CHANGE_LOG_RESOLVERS: Record = { [CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_FORWARDS_TO]: (translate, action) => getForwardsToUpdateMessage(translate, action), [CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_AUTO_PAY_APPROVED_REPORTS_ENABLED]: (translate, action) => getAutoPayApprovedReportsEnabledMessage(translate, action), [CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_AUTO_REIMBURSEMENT]: (translate, action) => getAutoReimbursementMessage(translate, action), + [CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_CATEGORY_TAX_RATE]: (translate, action) => getCategoryTaxRateMessage(translate, action), [CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_INVOICE_COMPANY_NAME]: (translate, action) => getInvoiceCompanyNameUpdateMessage(translate, action), [CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_INVOICE_COMPANY_WEBSITE]: (translate, action) => getInvoiceCompanyWebsiteUpdateMessage(translate, action), [CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_REIMBURSER]: (translate, action) => getReimburserUpdateMessage(translate, action), From 85ae38eb07a79ff0da554895eb4a45b79b7ca6a8 Mon Sep 17 00:00:00 2001 From: Rayane <77965000+rayane-d@users.noreply.github.com> Date: Wed, 6 May 2026 19:42:54 +0100 Subject: [PATCH 015/435] Add unit tests for `getCategoryTaxRateMessage` in ReportActionsUtilsTest --- tests/unit/ReportActionsUtilsTest.ts | 41 ++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/tests/unit/ReportActionsUtilsTest.ts b/tests/unit/ReportActionsUtilsTest.ts index 3ee7c18782f8..1d1fd08f445c 100644 --- a/tests/unit/ReportActionsUtilsTest.ts +++ b/tests/unit/ReportActionsUtilsTest.ts @@ -18,6 +18,7 @@ import { getAutoPayApprovedReportsEnabledMessage, getAutoReimbursementMessage, getCardIssuedMessage, + getCategoryTaxRateMessage, getCompanyAddressUpdateMessage, getCreatedReportForUnapprovedTransactionsMessage, getCurrencyDefaultTaxUpdateMessage, @@ -4418,6 +4419,46 @@ describe('ReportActionsUtils', () => { }); }); + describe('getCategoryTaxRateMessage', () => { + it('should render the changed-tax-rate message with both name and percentage on each side', () => { + const action = { + actionName: CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_CATEGORY_TAX_RATE, + reportActionID: '1', + created: '', + originalMessage: { + categoryName: 'Office Supplies', + oldTaxName: 'Tax Exempt', + oldTaxPercentage: '0%', + newTaxName: 'Tax Rate 1', + newTaxPercentage: '5%', + }, + message: [], + } as ReportAction; + + const result = getCategoryTaxRateMessage(translateLocal, action); + expect(result).toBe('changed the "Office Supplies" category default tax rate to "Tax Rate 1 (5%)" (previously "Tax Exempt (0%)")'); + }); + + it('should drop the parens on the side with an empty percentage (e.g. previous tax was deleted)', () => { + const action = { + actionName: CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_CATEGORY_TAX_RATE, + reportActionID: '1', + created: '', + originalMessage: { + categoryName: 'Office Supplies', + oldTaxName: 'Deleted Tax Rate', + oldTaxPercentage: '', + newTaxName: 'Tax Rate 1', + newTaxPercentage: '5%', + }, + message: [], + } as ReportAction; + + const result = getCategoryTaxRateMessage(translateLocal, action); + expect(result).toBe('changed the "Office Supplies" category default tax rate to "Tax Rate 1 (5%)" (previously "Deleted Tax Rate")'); + }); + }); + describe('isNewerReportAction', () => { const makeAction = (overrides: Partial): ReportAction => ({ From fb32fe45c015f7ec38e32b27e97aeca0f1b5a20e Mon Sep 17 00:00:00 2001 From: Rayane <77965000+rayane-d@users.noreply.github.com> Date: Wed, 6 May 2026 19:43:18 +0100 Subject: [PATCH 016/435] Add unit tests for handling `UPDATE_CATEGORY_TAX_RATE` action in OptionsListUtils, ReportNameUtils, and SidebarUtils. Ensure correct rendering of the message. --- tests/unit/OptionsListUtilsTest.tsx | 32 ++++++++++++++++++++ tests/unit/ReportNameUtilsTest.ts | 40 +++++++++++++++++++++++++ tests/unit/SidebarUtilsTest.ts | 46 +++++++++++++++++++++++++++++ 3 files changed, 118 insertions(+) diff --git a/tests/unit/OptionsListUtilsTest.tsx b/tests/unit/OptionsListUtilsTest.tsx index 7a8f6c339cb2..8bbf69453acd 100644 --- a/tests/unit/OptionsListUtilsTest.tsx +++ b/tests/unit/OptionsListUtilsTest.tsx @@ -4907,6 +4907,38 @@ describe('OptionsListUtils', () => { ); }); }); + describe('UPDATE_CATEGORY_TAX_RATE action', () => { + it('should surface the rendered category default tax rate change in the last-message preview', async () => { + const report: Report = createRandomReport(0, undefined); + const changelogAction: ReportAction = { + ...createRandomReportAction(1), + actionName: CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_CATEGORY_TAX_RATE, + message: [{type: 'COMMENT', text: ''}], + originalMessage: { + categoryName: 'Office Supplies', + oldTaxName: 'Tax Exempt', + oldTaxPercentage: '0%', + newTaxName: 'Tax Rate 1', + newTaxPercentage: '5%', + }, + }; + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report.reportID}`, { + [changelogAction.reportActionID]: changelogAction, + }); + + const lastMessage = getLastMessageTextForReport({ + translate: translateLocal, + report, + lastActorDetails: null, + policy: undefined, + isReportArchived: false, + + currentUserLogin: CURRENT_USER_EMAIL, + }); + + expect(lastMessage).toBe('changed the "Office Supplies" category default tax rate to "Tax Rate 1 (5%)" (previously "Tax Exempt (0%)")'); + }); + }); }); describe('getPersonalDetailSearchTerms', () => { diff --git a/tests/unit/ReportNameUtilsTest.ts b/tests/unit/ReportNameUtilsTest.ts index 9c18e5886707..1dce8a6ffd14 100644 --- a/tests/unit/ReportNameUtilsTest.ts +++ b/tests/unit/ReportNameUtilsTest.ts @@ -648,6 +648,46 @@ describe('ReportNameUtils', () => { expect(disabledName).toBe('disabled the company card purchases requirement'); }); + test('UPDATE_CATEGORY_TAX_RATE parent action renders the rendered category default tax rate change', () => { + const thread: Report = createWorkspaceThread(160); + const parentAction: ReportAction = { + actionName: CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_CATEGORY_TAX_RATE, + reportActionID: String(thread.parentReportActionID), + message: [], + created: '', + lastModified: '', + actorAccountID: 1, + person: [], + originalMessage: { + categoryName: 'Office Supplies', + oldTaxName: 'Tax Exempt', + oldTaxPercentage: '0%', + newTaxName: 'Tax Rate 1', + newTaxPercentage: '5%', + }, + } as unknown as ReportAction; + + const parentId = String(thread.parentReportID); + const actionId = String(thread.parentReportActionID); + const reportActionsCollection: Record = { + [`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${parentId}`]: { + [actionId]: parentAction, + }, + }; + + const name = computeReportName( + thread, + emptyCollections.reports, + emptyCollections.policies, + undefined, + undefined, + participantsPersonalDetails, + reportActionsCollection, + currentUserAccountID, + ); + expect(name).toBe('changed the "Office Supplies" category default tax rate to "Tax Rate 1 (5%)" (previously "Tax Exempt (0%)")'); + }); + test('DELETE_CARD_FEED parent action', () => { const thread: Report = createWorkspaceThread(101); const parentAction: ReportAction = { diff --git a/tests/unit/SidebarUtilsTest.ts b/tests/unit/SidebarUtilsTest.ts index 5d35405fd867..bee3e84de8b6 100644 --- a/tests/unit/SidebarUtilsTest.ts +++ b/tests/unit/SidebarUtilsTest.ts @@ -1873,6 +1873,52 @@ describe('SidebarUtils', () => { expect(result?.alternateText).toBe('changed the foreign currency default tax rate to "Foreign Tax (10%)" (previously "Foreign Tax (15%)")'); }); + it('returns the correct alternate text for UPDATE_CATEGORY_TAX_RATE action', async () => { + const report: Report = { + ...createRandomReport(4, 'policyAdmins'), + participants: {'18921695': {notificationPreference: 'always'}}, + }; + const lastAction: ReportAction = { + ...createRandomReportAction(2), + actionName: CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_CATEGORY_TAX_RATE, + originalMessage: { + categoryName: 'Office Supplies', + oldTaxName: 'Tax Exempt', + oldTaxPercentage: '0%', + newTaxName: 'Tax Rate 1', + newTaxPercentage: '5%', + }, + }; + const reportActions: ReportActions = {[lastAction.reportActionID]: lastAction}; + await act(async () => { + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${report.reportID}`, report); + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report.reportID}`, reportActions); + }); + + const result = SidebarUtils.getOptionData({ + report, + reportAttributes: undefined, + reportNameValuePairs: {}, + personalDetails: {}, + policy: undefined, + invoiceReceiverPolicy: undefined, + parentReportAction: undefined, + conciergeReportID: '', + oneTransactionThreadReport: undefined, + card: undefined, + translate: translateLocal, + localeCompare, + lastAction, + lastActionReport: undefined, + isReportArchived: undefined, + currentUserAccountID: 0, + currentUserLogin: CURRENT_USER_LOGIN, + reportAttributesDerived: undefined, + }); + + expect(result?.alternateText).toBe('changed the "Office Supplies" category default tax rate to "Tax Rate 1 (5%)" (previously "Tax Exempt (0%)")'); + }); + it('returns the correct alternate text for UPDATE_REQUIRE_COMPANY_CARDS_ENABLED action', async () => { const report: Report = { ...createRandomReport(4, 'policyAdmins'), From 0f3a701844bd15b8af3952f0ac6cd92016a9edae Mon Sep 17 00:00:00 2001 From: Rayane <77965000+rayane-d@users.noreply.github.com> Date: Thu, 7 May 2026 00:33:16 +0100 Subject: [PATCH 017/435] add Spanish translation --- src/languages/es.ts | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/src/languages/es.ts b/src/languages/es.ts index 5cf243f95099..d12045065ac9 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -7327,6 +7327,23 @@ ${amount} para ${merchant} - ${date}`, updatedAutoPayApprovedReportsLimit: ({oldLimit, newLimit}: {oldLimit: string; newLimit: string}) => `cambió el umbral de autopago de informes aprobados a "${newLimit}" (previamente "${oldLimit}")`, removedAutoPayApprovedReportsLimit: 'eliminó el umbral de autopago de informes aprobados', + updatedCategoryTaxRate: ({ + categoryName, + oldTaxName, + oldTaxPercentage, + newTaxName, + newTaxPercentage, + }: { + categoryName: string; + oldTaxName: string; + oldTaxPercentage: string; + newTaxName: string; + newTaxPercentage: string; + }) => { + const oldTax = oldTaxPercentage ? `${oldTaxName} (${oldTaxPercentage})` : oldTaxName; + const newTax = newTaxPercentage ? `${newTaxName} (${newTaxPercentage})` : newTaxName; + return `actualizó la tasa de impuesto predeterminada de la categoría "${categoryName}" a "${newTax}" (previamente "${oldTax}")`; + }, changedDefaultApprover: ({newApprover, previousApprover}: {newApprover: string; previousApprover?: string}) => previousApprover ? `cambió el aprobador predeterminado a ${newApprover} (anteriormente ${previousApprover})` : `cambió el aprobador predeterminado a ${newApprover}`, changedSubmitsToApprover: ({ From 87aed17ec3706673e852adf5c92de41278b3a3ef Mon Sep 17 00:00:00 2001 From: Rayane <77965000+rayane-d@users.noreply.github.com> Date: Thu, 7 May 2026 00:36:58 +0100 Subject: [PATCH 018/435] Refactor category tax rate update messages translations, simplified parameter structure in translation functions --- src/languages/en.ts | 16 +--------------- src/languages/es.ts | 19 ++----------------- src/libs/ReportActionsUtils.ts | 9 +++++---- 3 files changed, 8 insertions(+), 36 deletions(-) diff --git a/src/languages/en.ts b/src/languages/en.ts index 1b0a09b9e6e1..a03f19dc7822 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -7453,21 +7453,7 @@ const translations = { updatedAutoPayApprovedReportsLimit: ({oldLimit, newLimit}: {oldLimit: string; newLimit: string}) => `changed the auto-pay approved reports threshold to "${newLimit}" (previously "${oldLimit}")`, removedAutoPayApprovedReportsLimit: 'removed the auto-pay approved reports threshold', - updatedCategoryTaxRate: ({ - categoryName, - oldTaxName, - oldTaxPercentage, - newTaxName, - newTaxPercentage, - }: { - categoryName: string; - oldTaxName: string; - oldTaxPercentage: string; - newTaxName: string; - newTaxPercentage: string; - }) => { - const oldTax = oldTaxPercentage ? `${oldTaxName} (${oldTaxPercentage})` : oldTaxName; - const newTax = newTaxPercentage ? `${newTaxName} (${newTaxPercentage})` : newTaxName; + updatedCategoryTaxRate: ({categoryName, oldTax, newTax}: {categoryName: string; oldTax: string; newTax: string}) => { return `changed the "${categoryName}" category default tax rate to "${newTax}" (previously "${oldTax}")`; }, changedDefaultApprover: ({newApprover, previousApprover}: {newApprover: string; previousApprover?: string}) => diff --git a/src/languages/es.ts b/src/languages/es.ts index d12045065ac9..2d48e2671382 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -7327,23 +7327,8 @@ ${amount} para ${merchant} - ${date}`, updatedAutoPayApprovedReportsLimit: ({oldLimit, newLimit}: {oldLimit: string; newLimit: string}) => `cambió el umbral de autopago de informes aprobados a "${newLimit}" (previamente "${oldLimit}")`, removedAutoPayApprovedReportsLimit: 'eliminó el umbral de autopago de informes aprobados', - updatedCategoryTaxRate: ({ - categoryName, - oldTaxName, - oldTaxPercentage, - newTaxName, - newTaxPercentage, - }: { - categoryName: string; - oldTaxName: string; - oldTaxPercentage: string; - newTaxName: string; - newTaxPercentage: string; - }) => { - const oldTax = oldTaxPercentage ? `${oldTaxName} (${oldTaxPercentage})` : oldTaxName; - const newTax = newTaxPercentage ? `${newTaxName} (${newTaxPercentage})` : newTaxName; - return `actualizó la tasa de impuesto predeterminada de la categoría "${categoryName}" a "${newTax}" (previamente "${oldTax}")`; - }, + updatedCategoryTaxRate: ({categoryName, oldTax, newTax}: {categoryName: string; oldTax: string; newTax: string}) => + `actualizó la tasa de impuesto predeterminada de la categoría "${categoryName}" a "${newTax}" (previamente "${oldTax}")`, changedDefaultApprover: ({newApprover, previousApprover}: {newApprover: string; previousApprover?: string}) => previousApprover ? `cambió el aprobador predeterminado a ${newApprover} (anteriormente ${previousApprover})` : `cambió el aprobador predeterminado a ${newApprover}`, changedSubmitsToApprover: ({ diff --git a/src/libs/ReportActionsUtils.ts b/src/libs/ReportActionsUtils.ts index 8b466073746f..317827dcedf9 100644 --- a/src/libs/ReportActionsUtils.ts +++ b/src/libs/ReportActionsUtils.ts @@ -3460,12 +3460,13 @@ function getCategoryTaxRateMessage(translate: LocalizedTranslate, action: Report return getReportActionText(action); } + const oldTax = oldTaxPercentage ? `${oldTaxName} (${oldTaxPercentage})` : (oldTaxName ?? ''); + const newTax = newTaxPercentage ? `${newTaxName} (${newTaxPercentage})` : newTaxName; + return translate('workspaceActions.updatedCategoryTaxRate', { categoryName, - oldTaxName: oldTaxName ?? '', - oldTaxPercentage: oldTaxPercentage ?? '', - newTaxName, - newTaxPercentage: newTaxPercentage ?? '', + oldTax, + newTax, }); } From 96ebfa29c96353c61193e128bb73e8e3a0473bba Mon Sep 17 00:00:00 2001 From: "truph01 (via MelvinBot)" Date: Thu, 14 May 2026 06:31:42 +0000 Subject: [PATCH 019/435] Patch react-native-draggable-flatlist to unblock iOS autoscroll feedback loop On iOS, scrollToOffset({animated: true}) does not emit intermediate onScroll events, so the scrollOffset shared value never updates mid-scroll. This blocks the autoscroll feedback loop in useAutoScroll (hasScrolledToTarget stays false), preventing drag-to-reorder from scrolling beyond the visible viewport. The fix manually advances scrollOffset after each scrollToOffset call, keeping the animated scroll for smooth visuals while unblocking the next loop iteration. Co-authored-by: truph01 --- patches/react-native-draggable-flatlist/details.md | 8 ++++++++ ...list+4.0.3+003+fix-ios-autoscroll-feedback.patch | 13 +++++++++++++ 2 files changed, 21 insertions(+) create mode 100644 patches/react-native-draggable-flatlist/react-native-draggable-flatlist+4.0.3+003+fix-ios-autoscroll-feedback.patch diff --git a/patches/react-native-draggable-flatlist/details.md b/patches/react-native-draggable-flatlist/details.md index 8afd4dc971c9..c12b0dffcbf6 100644 --- a/patches/react-native-draggable-flatlist/details.md +++ b/patches/react-native-draggable-flatlist/details.md @@ -14,3 +14,11 @@ - Upstream PR/issue: https://github.com/computerjazz/react-native-draggable-flatlist/pull/544 - E/App issue: 🛑 - PR Introducing Patch: [#55066](https://github.com/Expensify/App/pull/55066) + + +### [react-native-draggable-flatlist+4.0.3+003+fix-ios-autoscroll-feedback.patch](react-native-draggable-flatlist+4.0.3+003+fix-ios-autoscroll-feedback.patch) + +- Reason: On iOS, `scrollToOffset({animated: true})` does not emit intermediate `onScroll` events, so the `scrollOffset` shared value never updates mid-scroll. This blocks the autoscroll feedback loop (`hasScrolledToTarget` stays false), preventing waypoint reordering beyond the visible viewport. The fix manually advances `scrollOffset` after each `scrollToOffset` call to keep the loop alive. +- Upstream PR/issue: https://github.com/computerjazz/react-native-draggable-flatlist/issues/509 +- E/App issue: [#87362](https://github.com/Expensify/App/issues/87362) +- PR Introducing Patch: 🛑 diff --git a/patches/react-native-draggable-flatlist/react-native-draggable-flatlist+4.0.3+003+fix-ios-autoscroll-feedback.patch b/patches/react-native-draggable-flatlist/react-native-draggable-flatlist+4.0.3+003+fix-ios-autoscroll-feedback.patch new file mode 100644 index 000000000000..4e331c5b7383 --- /dev/null +++ b/patches/react-native-draggable-flatlist/react-native-draggable-flatlist+4.0.3+003+fix-ios-autoscroll-feedback.patch @@ -0,0 +1,13 @@ +diff --git a/node_modules/react-native-draggable-flatlist/src/hooks/useAutoScroll.tsx b/node_modules/react-native-draggable-flatlist/src/hooks/useAutoScroll.tsx +index 4e30bca..b1a2f3e 100644 +--- a/node_modules/react-native-draggable-flatlist/src/hooks/useAutoScroll.tsx ++++ b/node_modules/react-native-draggable-flatlist/src/hooks/useAutoScroll.tsx +@@ -89,6 +89,8 @@ export function useAutoScroll() { + function scrollToInternal(offset: number) { + if (flatlistRef && "current" in flatlistRef) { + flatlistRef.current?.scrollToOffset({ offset, animated: true }); ++ // Manually advance scrollOffset so the autoscroll feedback loop is not blocked on iOS. ++ scrollOffset.value = offset; + } + } + From 8d509c5946889ba73c4087c6cdafd5b5e4ceac1d Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Mon, 18 May 2026 01:39:13 +0530 Subject: [PATCH 020/435] feat: replace distance rates list with Table UI behind beta gate Signed-off-by: krishna2323 --- src/CONST/index.ts | 6 + .../WorkspaceDistanceRatesTableRow.tsx | 155 ++++++++++++ .../WorkspaceDistanceRatesTable/index.tsx | 225 ++++++++++++++++++ src/languages/en.ts | 4 + src/libs/PolicyDistanceRatesUtils.ts | 17 +- .../distanceRates/PolicyDistanceRatesPage.tsx | 111 ++++++--- 6 files changed, 489 insertions(+), 29 deletions(-) create mode 100644 src/components/Tables/WorkspaceDistanceRatesTable/WorkspaceDistanceRatesTableRow.tsx create mode 100644 src/components/Tables/WorkspaceDistanceRatesTable/index.tsx diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 2d7249f0852e..af4c515bb721 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -4015,6 +4015,12 @@ const CONST = { FAKE_P2P_ID: '_FAKE_P2P_ID_', MILES_TO_KILOMETERS: 1.609344, KILOMETERS_TO_MILES: 0.621371, + RATE_STATUS: { + ACTIVE: 'active', + FUTURE: 'future', + EXPIRED: 'expired', + INACTIVE: 'inactive', + }, }, TERMS: { diff --git a/src/components/Tables/WorkspaceDistanceRatesTable/WorkspaceDistanceRatesTableRow.tsx b/src/components/Tables/WorkspaceDistanceRatesTable/WorkspaceDistanceRatesTableRow.tsx new file mode 100644 index 000000000000..505ece8ef54a --- /dev/null +++ b/src/components/Tables/WorkspaceDistanceRatesTable/WorkspaceDistanceRatesTableRow.tsx @@ -0,0 +1,155 @@ +import {format, parseISO} from 'date-fns'; +import React from 'react'; +import {View} from 'react-native'; +import Checkbox from '@components/Checkbox'; +import Icon from '@components/Icon'; +import Table from '@components/Table'; +import Text from '@components/Text'; +import TextWithTooltip from '@components/TextWithTooltip'; +import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; +import useTheme from '@hooks/useTheme'; +import useThemeStyles from '@hooks/useThemeStyles'; +import {getRateStatus} from '@libs/PolicyDistanceRatesUtils'; +import type {SkeletonSpanReasonAttributes} from '@libs/telemetry/useSkeletonSpan'; +import variables from '@styles/variables'; +import CONST from '@src/CONST'; +import type * as OnyxCommon from '@src/types/onyx/OnyxCommon'; +import type {Rate} from '@src/types/onyx/Policy'; + +type DistanceRateTableItemData = { + rateID: string; + rate: Rate; + formattedRate: string; + pendingAction?: OnyxCommon.PendingAction; + errors?: OnyxCommon.Errors; + onDismissError?: () => void; +}; + +type WorkspaceDistanceRatesTableRowProps = { + item: DistanceRateTableItemData; + rowIndex: number; + isSelected: boolean; + canSelectMultiple: boolean; + onToggle: () => void; + onPress: () => void; + shouldUseNarrowTableLayout: boolean; + statusLabels: Record; +}; + +function formatDateColumn(dateString: string | undefined): string { + if (!dateString) { + return ''; + } + return format(parseISO(dateString), CONST.DATE.MONTH_DAY_YEAR_FORMAT); +} + +function WorkspaceDistanceRatesTableRow({item, rowIndex, isSelected, canSelectMultiple, onToggle, onPress, shouldUseNarrowTableLayout, statusLabels}: WorkspaceDistanceRatesTableRowProps) { + const theme = useTheme(); + const styles = useThemeStyles(); + const Expensicons = useMemoizedLazyExpensifyIcons(['ArrowRight']); + + const {rate, formattedRate, pendingAction, errors, onDismissError} = item; + const isDeleting = pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE; + + const status = getRateStatus(rate); + + const reasonAttributes: SkeletonSpanReasonAttributes = { + context: 'WorkspaceDistanceRatesTableItem', + isDeleting, + }; + + return ( + + {({hovered}) => ( + <> + {canSelectMultiple && ( + + + + )} + + + + {shouldUseNarrowTableLayout && ( + + )} + + + {!shouldUseNarrowTableLayout && ( + + + {rate.name} + + + )} + + {!shouldUseNarrowTableLayout && ( + + + {formattedRate} + + + )} + + {!shouldUseNarrowTableLayout && ( + + + {formatDateColumn(rate.startDate)} + + + )} + + {!shouldUseNarrowTableLayout && ( + + + {formatDateColumn(rate.endDate)} + + + )} + + + + )} + + ); +} + +export default WorkspaceDistanceRatesTableRow; +export type {DistanceRateTableItemData}; diff --git a/src/components/Tables/WorkspaceDistanceRatesTable/index.tsx b/src/components/Tables/WorkspaceDistanceRatesTable/index.tsx new file mode 100644 index 000000000000..d959cdbd1104 --- /dev/null +++ b/src/components/Tables/WorkspaceDistanceRatesTable/index.tsx @@ -0,0 +1,225 @@ +import type {ListRenderItemInfo} from '@shopify/flash-list'; +import React, {useEffect, useMemo, useRef, useState} from 'react'; +import {View} from 'react-native'; +import Checkbox from '@components/Checkbox'; +import Table from '@components/Table'; +import type {ActiveSorting, CompareItemsCallback, IsItemInSearchCallback, TableColumn, TableHandle} from '@components/Table'; +import useBottomSafeSafeAreaPaddingStyle from '@hooks/useBottomSafeSafeAreaPaddingStyle'; +import useLocalize from '@hooks/useLocalize'; +import useResponsiveLayout from '@hooks/useResponsiveLayout'; +import useThemeStyles from '@hooks/useThemeStyles'; +import {convertAmountToDisplayString} from '@libs/CurrencyUtils'; +import {getRateStatus} from '@libs/PolicyDistanceRatesUtils'; +import tokenizedSearch from '@libs/tokenizedSearch'; +import CONST from '@src/CONST'; +import type * as OnyxCommon from '@src/types/onyx/OnyxCommon'; +import type {Rate} from '@src/types/onyx/Policy'; +import WorkspaceDistanceRatesTableRow from './WorkspaceDistanceRatesTableRow'; +import type {DistanceRateTableItemData} from './WorkspaceDistanceRatesTableRow'; + +type DistanceRatesTableColumnKey = 'status' | 'name' | 'rate' | 'startDate' | 'endDate'; + +type WorkspaceDistanceRatesTableProps = { + customUnitRates: Record; + unitTranslation: string; + selectedDistanceRates: string[]; + canSelectMultiple: boolean; + onToggleRate: (rateID: string) => void; + onToggleAllRates: () => void; + onPressRate: (rateID: string) => void; + onDismissError: (rateID: string) => void; + pendingAction?: OnyxCommon.PendingAction; + pendingFields?: OnyxCommon.PendingFields; +}; + +const STATUS_ORDER: Record = { + [CONST.CUSTOM_UNITS.RATE_STATUS.ACTIVE]: 0, + [CONST.CUSTOM_UNITS.RATE_STATUS.FUTURE]: 1, + [CONST.CUSTOM_UNITS.RATE_STATUS.EXPIRED]: 2, + [CONST.CUSTOM_UNITS.RATE_STATUS.INACTIVE]: 3, +}; + +function WorkspaceDistanceRatesTable({ + customUnitRates, + unitTranslation, + selectedDistanceRates, + canSelectMultiple, + onToggleRate, + onToggleAllRates, + onPressRate, + onDismissError, + pendingAction, + pendingFields, +}: WorkspaceDistanceRatesTableProps) { + const styles = useThemeStyles(); + const {translate, localeCompare} = useLocalize(); + const {shouldUseNarrowLayout, isMediumScreenWidth} = useResponsiveLayout(); + const shouldUseNarrowTableLayout = shouldUseNarrowLayout || isMediumScreenWidth; + + const tableRef = useRef>(null); + + const columns: Array> = [ + {key: 'status', label: translate('workspace.distanceRates.status')}, + {key: 'name', label: translate('common.name')}, + {key: 'rate', label: translate('workspace.distanceRates.rate')}, + {key: 'startDate', label: translate('workspace.distanceRates.startDate')}, + {key: 'endDate', label: translate('workspace.distanceRates.endDate')}, + ]; + + const ratesData: DistanceRateTableItemData[] = Object.values(customUnitRates).map((rate) => { + const resolvedPendingAction = + rate.pendingAction ?? + rate.pendingFields?.rate ?? + rate.pendingFields?.enabled ?? + rate.pendingFields?.currency ?? + rate.pendingFields?.name ?? + pendingFields?.attributes ?? + (pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD ? pendingAction : undefined); + + return { + rateID: rate.customUnitRateID, + rate, + formattedRate: `${convertAmountToDisplayString(rate.rate, rate.currency ?? CONST.CURRENCY.USD)} / ${unitTranslation}`, + pendingAction: resolvedPendingAction ?? undefined, + errors: rate.errors ?? undefined, + onDismissError: () => onDismissError(rate.customUnitRateID), + }; + }); + + const keyExtractor = (item: DistanceRateTableItemData) => item.rateID; + + const tableBodyContentContainerStyle = useBottomSafeSafeAreaPaddingStyle({ + addBottomSafeAreaPadding: true, + addOfflineIndicatorBottomSafeAreaPadding: true, + style: styles.pb4, + }); + + const compareItems: CompareItemsCallback = (a, b, activeSorting) => { + const orderMultiplier = activeSorting.order === 'asc' ? 1 : -1; + + if (activeSorting.columnKey === 'status') { + const aStatus = getRateStatus(a.rate); + const bStatus = getRateStatus(b.rate); + const diff = (STATUS_ORDER[aStatus] ?? 0) - (STATUS_ORDER[bStatus] ?? 0); + if (diff !== 0) { + return diff * orderMultiplier; + } + return localeCompare(a.rate.name ?? '', b.rate.name ?? '') * orderMultiplier; + } + + if (activeSorting.columnKey === 'name') { + return localeCompare(a.rate.name ?? '', b.rate.name ?? '') * orderMultiplier; + } + + if (activeSorting.columnKey === 'rate') { + return ((a.rate.rate ?? 0) - (b.rate.rate ?? 0)) * orderMultiplier; + } + + if (activeSorting.columnKey === 'startDate') { + return localeCompare(a.rate.startDate ?? '', b.rate.startDate ?? '') * orderMultiplier; + } + + if (activeSorting.columnKey === 'endDate') { + return localeCompare(a.rate.endDate ?? '', b.rate.endDate ?? '') * orderMultiplier; + } + + return 0; + }; + + const isItemInSearch: IsItemInSearchCallback = (item, searchString) => { + const matchingItems = tokenizedSearch([item], searchString, (i) => [i.rate.name ?? '', i.formattedRate]); + return matchingItems.length > 0; + }; + + const statusLabels = useMemo( + () => ({ + [CONST.CUSTOM_UNITS.RATE_STATUS.ACTIVE]: translate('workspace.distanceRates.statusActive'), + [CONST.CUSTOM_UNITS.RATE_STATUS.FUTURE]: translate('workspace.distanceRates.statusFuture'), + [CONST.CUSTOM_UNITS.RATE_STATUS.EXPIRED]: translate('workspace.distanceRates.statusExpired'), + [CONST.CUSTOM_UNITS.RATE_STATUS.INACTIVE]: translate('workspace.distanceRates.statusInactive'), + }), + [translate], + ); + + const renderItem = ({item, index}: ListRenderItemInfo) => ( + onToggleRate(item.rateID)} + onPress={() => onPressRate(item.rateID)} + shouldUseNarrowTableLayout={shouldUseNarrowTableLayout} + statusLabels={statusLabels} + /> + ); + + const isNarrowLayoutRef = useRef(shouldUseNarrowTableLayout); + const [activeSortingInWideLayout, setActiveSortingInWideLayout] = useState | undefined>(undefined); + + useEffect(() => { + if (shouldUseNarrowTableLayout) { + if (isNarrowLayoutRef.current) { + return; + } + isNarrowLayoutRef.current = true; + const activeSorting = tableRef.current?.getActiveSorting(); + setActiveSortingInWideLayout(activeSorting); + tableRef.current?.updateSorting({columnKey: 'name', order: 'asc'}); + return; + } + + if (!activeSortingInWideLayout || !isNarrowLayoutRef.current) { + return; + } + + isNarrowLayoutRef.current = false; + tableRef.current?.updateSorting(activeSortingInWideLayout); + }, [activeSortingInWideLayout, shouldUseNarrowTableLayout]); + + const allSelected = ratesData.length > 0 && ratesData.every((item) => selectedDistanceRates.includes(item.rateID)); + const someSelected = selectedDistanceRates.length > 0 && !allSelected; + + const SelectAllHeader = canSelectMultiple ? ( + + + + ) : null; + + const ListHeader = !shouldUseNarrowTableLayout ? : null; + + return ( + + {!shouldUseNarrowTableLayout && ( + + {SelectAllHeader} + + + + + )} + + +
+ ); +} + +export default WorkspaceDistanceRatesTable; +export type {DistanceRateTableItemData, DistanceRatesTableColumnKey}; diff --git a/src/languages/en.ts b/src/languages/en.ts index 4b26adf07f40..cab9e3ea4a73 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -6570,6 +6570,10 @@ const translations = { }), enableRate: 'Enable rate', status: 'Status', + statusActive: 'Active', + statusFuture: 'Future', + statusExpired: 'Expired', + statusInactive: 'Inactive', unit: 'Unit', taxFeatureNotEnabledMessage: 'Taxes must be enabled on the workspace to use this feature. Head over to More features to make that change.', diff --git a/src/libs/PolicyDistanceRatesUtils.ts b/src/libs/PolicyDistanceRatesUtils.ts index 4d046ffe7e1d..44ccbf25add9 100644 --- a/src/libs/PolicyDistanceRatesUtils.ts +++ b/src/libs/PolicyDistanceRatesUtils.ts @@ -1,5 +1,6 @@ import type {NullishDeep, OnyxUpdate} from 'react-native-onyx'; import Onyx from 'react-native-onyx'; +import type {ValueOf} from 'type-fest'; import type {FormInputErrors, FormOnyxValues} from '@components/Form/types'; import type {LocalizedTranslate} from '@components/LocaleContextProvider'; import CONST from '@src/CONST'; @@ -78,6 +79,20 @@ function validateCreateDistanceRateForm( return errors; } +function getRateStatus(rate: Rate): ValueOf { + if (!rate.enabled) { + return CONST.CUSTOM_UNITS.RATE_STATUS.INACTIVE; + } + const today = new Date().toISOString().split('T').at(0) ?? ''; + if (rate.startDate && rate.startDate > today) { + return CONST.CUSTOM_UNITS.RATE_STATUS.FUTURE; + } + if (rate.endDate && rate.endDate < today) { + return CONST.CUSTOM_UNITS.RATE_STATUS.EXPIRED; + } + return CONST.CUSTOM_UNITS.RATE_STATUS.ACTIVE; +} + type PolicyDistanceRateUpdateField = keyof Pick | keyof TaxRateAttributes; /** @@ -164,4 +179,4 @@ function buildOnyxDataForPolicyDistanceRateUpdates( return {optimisticData, successData, failureData}; } -export {validateRateValue, getOptimisticRateName, validateTaxClaimableValue, validateCreateDistanceRateForm, buildOnyxDataForPolicyDistanceRateUpdates}; +export {validateRateValue, getOptimisticRateName, validateTaxClaimableValue, validateCreateDistanceRateForm, getRateStatus, buildOnyxDataForPolicyDistanceRateUpdates}; diff --git a/src/pages/workspace/distanceRates/PolicyDistanceRatesPage.tsx b/src/pages/workspace/distanceRates/PolicyDistanceRatesPage.tsx index ae20b656115f..324ce9c45c21 100644 --- a/src/pages/workspace/distanceRates/PolicyDistanceRatesPage.tsx +++ b/src/pages/workspace/distanceRates/PolicyDistanceRatesPage.tsx @@ -15,6 +15,7 @@ import type {ListItem} from '@components/SelectionList/types'; import SelectionListWithModal from '@components/SelectionListWithModal'; import CustomListHeader from '@components/SelectionListWithModal/CustomListHeader'; import Switch from '@components/Switch'; +import WorkspaceDistanceRatesTable from '@components/Tables/WorkspaceDistanceRatesTable'; import Text from '@components/Text'; import useConfirmModal from '@hooks/useConfirmModal'; import useFilteredSelection from '@hooks/useFilteredSelection'; @@ -23,6 +24,7 @@ import useLocalize from '@hooks/useLocalize'; import useMobileSelectionMode from '@hooks/useMobileSelectionMode'; import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; +import usePermissions from '@hooks/usePermissions'; import usePolicy from '@hooks/usePolicy'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useSearchBackPress from '@hooks/useSearchBackPress'; @@ -66,6 +68,8 @@ function PolicyDistanceRatesPage({ }, }: PolicyDistanceRatesPageProps) { const icons = useMemoizedLazyExpensifyIcons(['Checkmark', 'Close', 'Gear', 'Plus', 'Trashcan']); + const {isBetaEnabled} = usePermissions(); + const isDateBoundMileageRateEnabled = isBetaEnabled(CONST.BETAS.DATE_BOUND_MILEAGE_RATE); const {shouldUseNarrowLayout, isInLandscapeMode} = useResponsiveLayout(); const styles = useThemeStyles(); const {translate, localeCompare} = useLocalize(); @@ -326,13 +330,20 @@ function PolicyDistanceRatesPage({ setSelectedDistanceRates([]); }; + const toggleRateByID = useCallback( + (rateID: string) => { + setSelectedDistanceRates((prevSelectedRates) => { + if (prevSelectedRates.includes(rateID)) { + return prevSelectedRates.filter((selectedRate) => selectedRate !== rateID); + } + return [...prevSelectedRates, rateID]; + }); + }, + [setSelectedDistanceRates], + ); + const toggleRate = (rate: RateForList) => { - setSelectedDistanceRates((prevSelectedRates) => { - if (prevSelectedRates.includes(rate.value)) { - return prevSelectedRates.filter((selectedRate) => selectedRate !== rate.value); - } - return [...prevSelectedRates, rate.value]; - }); + toggleRateByID(rate.value); }; const toggleAllRates = () => { @@ -347,6 +358,36 @@ function PolicyDistanceRatesPage({ } }; + const toggleAllRatesForTable = useCallback(() => { + if (selectedDistanceRates.length > 0) { + setSelectedDistanceRates([]); + } else { + setSelectedDistanceRates( + Object.entries(selectableRates) + .filter(([, rate]) => rate.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE) + .map(([key]) => key), + ); + } + }, [selectedDistanceRates.length, selectableRates, setSelectedDistanceRates]); + + const dismissErrorByID = (rateID: string) => { + if (!customUnit?.customUnitID) { + return; + } + if (customUnitRates[rateID]?.errors) { + clearDeleteDistanceRateError(policyID, customUnit.customUnitID, rateID); + return; + } + clearCreateDistanceRateItemAndError(policyID, customUnit.customUnitID, rateID); + }; + + const openRateDetailsByID = useCallback( + (rateID: string) => { + Navigation.navigate(ROUTES.WORKSPACE_DISTANCE_RATE_DETAILS.getRoute(policyID, rateID)); + }, + [policyID], + ); + const getCustomListHeader = () => { if (filteredDistanceRatesList.length === 0) { return null; @@ -525,28 +566,42 @@ function PolicyDistanceRatesPage({ reasonAttributes={reasonAttributes} /> )} - {Object.values(customUnitRates).length > 0 && ( - item && toggleRate(item)} - onSelectAll={filteredDistanceRatesList.length > 0 ? toggleAllRates : undefined} - shouldPreventDefaultFocusOnSelectRow={!canUseTouchScreen()} - customListHeaderContent={headerContent} - canSelectMultiple={canSelectMultiple} - selectAllAccessibilityLabel={translate('accessibilityHints.selectAllDistanceRates')} - onDismissError={dismissError} - shouldShowListEmptyContent={false} - showScrollIndicator={false} - turnOnSelectionModeOnLongPress - shouldHeaderBeInsideList - shouldShowRightCaret - /> - )} + {Object.values(customUnitRates).length > 0 && + (isDateBoundMileageRateEnabled ? ( + + ) : ( + item && toggleRate(item)} + onSelectAll={filteredDistanceRatesList.length > 0 ? toggleAllRates : undefined} + shouldPreventDefaultFocusOnSelectRow={!canUseTouchScreen()} + customListHeaderContent={headerContent} + canSelectMultiple={canSelectMultiple} + selectAllAccessibilityLabel={translate('accessibilityHints.selectAllDistanceRates')} + onDismissError={dismissError} + shouldShowListEmptyContent={false} + showScrollIndicator={false} + turnOnSelectionModeOnLongPress + shouldHeaderBeInsideList + shouldShowRightCaret + /> + ))} ); From 8646759f6d009cfeb86c07889ef02b1d7c65cb47 Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Mon, 18 May 2026 02:05:50 +0530 Subject: [PATCH 021/435] fix: add mobile selection mode entry via long-press and search bar to distance rates table Signed-off-by: krishna2323 --- .../WorkspaceDistanceRatesTableRow.tsx | 14 ++++++++++- .../WorkspaceDistanceRatesTable/index.tsx | 25 ++++++++++++++----- .../distanceRates/PolicyDistanceRatesPage.tsx | 13 +++++++++- 3 files changed, 44 insertions(+), 8 deletions(-) diff --git a/src/components/Tables/WorkspaceDistanceRatesTable/WorkspaceDistanceRatesTableRow.tsx b/src/components/Tables/WorkspaceDistanceRatesTable/WorkspaceDistanceRatesTableRow.tsx index 505ece8ef54a..8c729a92e36e 100644 --- a/src/components/Tables/WorkspaceDistanceRatesTable/WorkspaceDistanceRatesTableRow.tsx +++ b/src/components/Tables/WorkspaceDistanceRatesTable/WorkspaceDistanceRatesTableRow.tsx @@ -32,6 +32,7 @@ type WorkspaceDistanceRatesTableRowProps = { canSelectMultiple: boolean; onToggle: () => void; onPress: () => void; + onLongPress?: () => void; shouldUseNarrowTableLayout: boolean; statusLabels: Record; }; @@ -43,7 +44,17 @@ function formatDateColumn(dateString: string | undefined): string { return format(parseISO(dateString), CONST.DATE.MONTH_DAY_YEAR_FORMAT); } -function WorkspaceDistanceRatesTableRow({item, rowIndex, isSelected, canSelectMultiple, onToggle, onPress, shouldUseNarrowTableLayout, statusLabels}: WorkspaceDistanceRatesTableRowProps) { +function WorkspaceDistanceRatesTableRow({ + item, + rowIndex, + isSelected, + canSelectMultiple, + onToggle, + onPress, + onLongPress, + shouldUseNarrowTableLayout, + statusLabels, +}: WorkspaceDistanceRatesTableRowProps) { const theme = useTheme(); const styles = useThemeStyles(); const Expensicons = useMemoizedLazyExpensifyIcons(['ArrowRight']); @@ -67,6 +78,7 @@ function WorkspaceDistanceRatesTableRow({item, rowIndex, isSelected, canSelectMu sentryLabel={CONST.SENTRY_LABEL.WORKSPACE.DISTANCE_RATES.ADD_BUTTON} offlineWithFeedback={{errors, pendingAction, onClose: onDismissError, shouldHideOnDelete: false}} onPress={onPress} + onLongPress={onLongPress} > {({hovered}) => ( <> diff --git a/src/components/Tables/WorkspaceDistanceRatesTable/index.tsx b/src/components/Tables/WorkspaceDistanceRatesTable/index.tsx index d959cdbd1104..13d528ebcb3b 100644 --- a/src/components/Tables/WorkspaceDistanceRatesTable/index.tsx +++ b/src/components/Tables/WorkspaceDistanceRatesTable/index.tsx @@ -27,6 +27,7 @@ type WorkspaceDistanceRatesTableProps = { onToggleRate: (rateID: string) => void; onToggleAllRates: () => void; onPressRate: (rateID: string) => void; + onLongPressRate?: (rateID: string) => void; onDismissError: (rateID: string) => void; pendingAction?: OnyxCommon.PendingAction; pendingFields?: OnyxCommon.PendingFields; @@ -47,6 +48,7 @@ function WorkspaceDistanceRatesTable({ onToggleRate, onToggleAllRates, onPressRate, + onLongPressRate, onDismissError, pendingAction, pendingFields, @@ -150,6 +152,7 @@ function WorkspaceDistanceRatesTable({ canSelectMultiple={canSelectMultiple} onToggle={() => onToggleRate(item.rateID)} onPress={() => onPressRate(item.rateID)} + onLongPress={onLongPressRate ? () => onLongPressRate(item.rateID) : undefined} shouldUseNarrowTableLayout={shouldUseNarrowTableLayout} statusLabels={statusLabels} /> @@ -192,7 +195,14 @@ function WorkspaceDistanceRatesTable({
) : null; - const ListHeader = !shouldUseNarrowTableLayout ? : null; + const shouldShowSearchBar = Object.keys(customUnitRates).length > CONST.SEARCH_ITEM_LIMIT; + + const ListHeader = ( + <> + {shouldShowSearchBar && } + {!shouldUseNarrowTableLayout && } + + ); return ( {!shouldUseNarrowTableLayout && ( - - {SelectAllHeader} - - + <> + {shouldShowSearchBar && } + + {SelectAllHeader} + + + - + )} diff --git a/src/pages/workspace/distanceRates/PolicyDistanceRatesPage.tsx b/src/pages/workspace/distanceRates/PolicyDistanceRatesPage.tsx index 324ce9c45c21..914ba3f5aea2 100644 --- a/src/pages/workspace/distanceRates/PolicyDistanceRatesPage.tsx +++ b/src/pages/workspace/distanceRates/PolicyDistanceRatesPage.tsx @@ -33,7 +33,7 @@ import useShouldDisplayButtonsInSeparateLine from '@hooks/useShouldDisplayButton import useThemeStyles from '@hooks/useThemeStyles'; import useTransactionViolation from '@hooks/useTransactionViolation'; import useWorkspaceDocumentTitle from '@hooks/useWorkspaceDocumentTitle'; -import {turnOffMobileSelectionMode} from '@libs/actions/MobileSelectionMode'; +import {turnOffMobileSelectionMode, turnOnMobileSelectionMode} from '@libs/actions/MobileSelectionMode'; import { clearCreateDistanceRateItemAndError, clearDeleteDistanceRateError, @@ -388,6 +388,16 @@ function PolicyDistanceRatesPage({ [policyID], ); + const handleLongPressRate = useCallback( + (rateID: string) => { + if (shouldUseNarrowLayout && !isMobileSelectionModeEnabled) { + turnOnMobileSelectionMode(); + } + toggleRateByID(rateID); + }, + [shouldUseNarrowLayout, isMobileSelectionModeEnabled, toggleRateByID], + ); + const getCustomListHeader = () => { if (filteredDistanceRatesList.length === 0) { return null; @@ -576,6 +586,7 @@ function PolicyDistanceRatesPage({ onToggleRate={toggleRateByID} onToggleAllRates={toggleAllRatesForTable} onPressRate={openRateDetailsByID} + onLongPressRate={handleLongPressRate} onDismissError={dismissErrorByID} pendingAction={policy?.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD ? policy?.pendingAction : undefined} pendingFields={customUnit?.pendingFields} From 365fd48c70c954ce5d6a2e9351e82784dbe88918 Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Sun, 24 May 2026 02:56:11 +0530 Subject: [PATCH 022/435] fix: add missing getRateStatus export and fix SEARCH_ITEM_LIMIT reference Signed-off-by: krishna2323 --- .../WorkspaceDistanceRatesTable/index.tsx | 2 +- src/libs/PolicyDistanceRatesUtils.ts | 21 +++++++++++++++++-- 2 files changed, 20 insertions(+), 3 deletions(-) diff --git a/src/components/Tables/WorkspaceDistanceRatesTable/index.tsx b/src/components/Tables/WorkspaceDistanceRatesTable/index.tsx index 13d528ebcb3b..783146b3e584 100644 --- a/src/components/Tables/WorkspaceDistanceRatesTable/index.tsx +++ b/src/components/Tables/WorkspaceDistanceRatesTable/index.tsx @@ -195,7 +195,7 @@ function WorkspaceDistanceRatesTable({ ) : null; - const shouldShowSearchBar = Object.keys(customUnitRates).length > CONST.SEARCH_ITEM_LIMIT; + const shouldShowSearchBar = Object.keys(customUnitRates).length >= CONST.STANDARD_LIST_ITEM_LIMIT; const ListHeader = ( <> diff --git a/src/libs/PolicyDistanceRatesUtils.ts b/src/libs/PolicyDistanceRatesUtils.ts index a024f6c58d23..121427c8de37 100644 --- a/src/libs/PolicyDistanceRatesUtils.ts +++ b/src/libs/PolicyDistanceRatesUtils.ts @@ -1,6 +1,5 @@ import type {NullishDeep, OnyxUpdate} from 'react-native-onyx'; import Onyx from 'react-native-onyx'; -import type {ValueOf} from 'type-fest'; import type {FormInputErrors, FormOnyxValues} from '@components/Form/types'; import type {LocalizedTranslate} from '@components/LocaleContextProvider'; import CONST from '@src/CONST'; @@ -172,4 +171,22 @@ function buildOnyxDataForPolicyDistanceRateUpdates( return {optimisticData, successData, failureData}; } -export {validateRateValue, getOptimisticRateName, validateTaxClaimableValue, validateCreateDistanceRateForm, buildOnyxDataForPolicyDistanceRateUpdates}; +function getRateStatus(rate: Rate): string { + if (!rate.enabled) { + return CONST.CUSTOM_UNITS.RATE_STATUS.INACTIVE; + } + + const now = new Date().toISOString().slice(0, 10); + + if (rate.startDate && rate.startDate > now) { + return CONST.CUSTOM_UNITS.RATE_STATUS.FUTURE; + } + + if (rate.endDate && rate.endDate < now) { + return CONST.CUSTOM_UNITS.RATE_STATUS.EXPIRED; + } + + return CONST.CUSTOM_UNITS.RATE_STATUS.ACTIVE; +} + +export {validateRateValue, getOptimisticRateName, validateTaxClaimableValue, validateCreateDistanceRateForm, buildOnyxDataForPolicyDistanceRateUpdates, getRateStatus}; From 1199736219d74ea34a9953ea7658b81afed05f1f Mon Sep 17 00:00:00 2001 From: "Issa Nimaga (via MelvinBot)" Date: Mon, 25 May 2026 09:08:35 +0000 Subject: [PATCH 023/435] Remove ViewStyle contamination from MenuItem subtitle Text element The subtitle Text in MenuItem was receiving combinedStyle which includes styles.popoverMenuItem (flexDirection, paddingHorizontal: 20, paddingVertical: 12, justifyContent, width: 100%). These ViewStyle properties on a Text element cause Android-specific layout shifts when the subtitle content changes width (e.g., member count going from single to double digits). Co-authored-by: Issa Nimaga --- src/components/MenuItem.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/MenuItem.tsx b/src/components/MenuItem.tsx index f8e7a748565e..1b281eec777e 100644 --- a/src/components/MenuItem.tsx +++ b/src/components/MenuItem.tsx @@ -1111,7 +1111,7 @@ function MenuItem({ {/* Since subtitle can be of type number, we should allow 0 to be shown */} {(subtitle === 0 || !!subtitle) && ( - {subtitle} + {subtitle} )} {(!!rightIconAccountID || !!rightIconReportID) && ( From 2c879fc60ae82df01816a9c5ce88ef8bf205a01c Mon Sep 17 00:00:00 2001 From: Rayane <77965000+rayane-d@users.noreply.github.com> Date: Wed, 27 May 2026 02:08:56 +0100 Subject: [PATCH 024/435] fix: decode category name in tax rate message --- src/libs/ReportActionsUtils.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/libs/ReportActionsUtils.ts b/src/libs/ReportActionsUtils.ts index 2d2879665adc..fa80eb535a8b 100644 --- a/src/libs/ReportActionsUtils.ts +++ b/src/libs/ReportActionsUtils.ts @@ -3444,7 +3444,7 @@ function getCategoryTaxRateMessage(translate: LocalizedTranslate, action: Report const newTax = newTaxPercentage ? `${newTaxName} (${newTaxPercentage})` : newTaxName; return translate('workspaceActions.updatedCategoryTaxRate', { - categoryName, + categoryName: getDecodedCategoryName(categoryName), oldTax, newTax, }); From 1e43adefb1b73452cabbc825f6737470a748b01e Mon Sep 17 00:00:00 2001 From: Adam Horodyski Date: Wed, 27 May 2026 13:32:57 +0200 Subject: [PATCH 025/435] SequentialQueue: await persist in push() before flush() --- src/libs/Network/SequentialQueue.ts | 38 +++++++---------------------- 1 file changed, 9 insertions(+), 29 deletions(-) diff --git a/src/libs/Network/SequentialQueue.ts b/src/libs/Network/SequentialQueue.ts index ab23074658ca..7586f1473251 100644 --- a/src/libs/Network/SequentialQueue.ts +++ b/src/libs/Network/SequentialQueue.ts @@ -504,7 +504,7 @@ async function handleConflictActions(conflictAction: Confl } } -function push(newRequest: OnyxRequest): Promise { +async function push(newRequest: OnyxRequest): Promise { const currentRequests = getAllPersistedRequests(); Log.info('[SequentialQueue] push() called', false, { command: newRequest.command, @@ -514,19 +514,10 @@ function push(newRequest: OnyxRequest): Promise; if (newRequest.checkAndFixConflictingRequest) { - const requests = currentRequests; - Log.info('[SequentialQueue] Checking for conflicts', false, { - command: newRequest.command, - existingRequestsCount: requests.length, - }); - - const {conflictAction} = newRequest.checkAndFixConflictingRequest(requests as Array>); + const {conflictAction} = newRequest.checkAndFixConflictingRequest(currentRequests as Array>); Log.info('[SequentialQueue] Conflict action determined', false, { command: newRequest.command, conflictType: conflictAction.type, @@ -537,41 +528,30 @@ function push(newRequest: OnyxRequest): Promise { - Log.info('[SequentialQueue] isReadyPromise resolved, flushing queue', false, { - command: newRequest.command, - }); - flush(true); - }); - return persistencePromise; + isReadyPromise.then(() => flush(true)); + return; } - Log.info('[SequentialQueue] Queue is not running. Flushing the queue.', false, { - command: newRequest.command, - }); flush(true); - return persistencePromise; } function getCurrentRequest(): Promise { From d4aa74695797f63ee934be6d5d8da3b67266a216 Mon Sep 17 00:00:00 2001 From: Adam Horodyski Date: Wed, 27 May 2026 13:35:42 +0200 Subject: [PATCH 026/435] SequentialQueue: restore push() flush trigger logs --- src/libs/Network/SequentialQueue.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/libs/Network/SequentialQueue.ts b/src/libs/Network/SequentialQueue.ts index 7586f1473251..4ad2c056d7cc 100644 --- a/src/libs/Network/SequentialQueue.ts +++ b/src/libs/Network/SequentialQueue.ts @@ -547,10 +547,18 @@ async function push(newRequest: OnyxRequest): Promis Log.info('[SequentialQueue] Queue is running. Will flush when the current request is finished.', false, { command: newRequest.command, }); - isReadyPromise.then(() => flush(true)); + isReadyPromise.then(() => { + Log.info('[SequentialQueue] isReadyPromise resolved, flushing queue', false, { + command: newRequest.command, + }); + flush(true); + }); return; } + Log.info('[SequentialQueue] Queue is not running. Flushing the queue.', false, { + command: newRequest.command, + }); flush(true); } From 96aceaecba1f48412ab9f1be9c34c19568319f85 Mon Sep 17 00:00:00 2001 From: Adam Horodyski Date: Wed, 27 May 2026 13:52:55 +0200 Subject: [PATCH 027/435] SequentialQueue: defer flush onto persistencePromise; update tests --- src/libs/Network/SequentialQueue.ts | 30 ++-- tests/unit/SequentialQueueTest.ts | 222 ++++++++++++++-------------- 2 files changed, 132 insertions(+), 120 deletions(-) diff --git a/src/libs/Network/SequentialQueue.ts b/src/libs/Network/SequentialQueue.ts index 4ad2c056d7cc..503d354df5db 100644 --- a/src/libs/Network/SequentialQueue.ts +++ b/src/libs/Network/SequentialQueue.ts @@ -504,7 +504,7 @@ async function handleConflictActions(conflictAction: Confl } } -async function push(newRequest: OnyxRequest): Promise { +function push(newRequest: OnyxRequest): Promise { const currentRequests = getAllPersistedRequests(); Log.info('[SequentialQueue] push() called', false, { command: newRequest.command, @@ -531,35 +531,39 @@ async function push(newRequest: OnyxRequest): Promis persistencePromise = savePersistedRequest(newRequest); } - // Block until the Onyx disk commit lands so a process kill between here and the XHR - // still leaves the request recoverable from storage on next launch. - await persistencePromise; - + // If we are offline we don't need to trigger the queue to empty as it will happen when we come back online if (isOfflineNetwork()) { Log.info('[SequentialQueue] Request persisted but not flushing — we are offline', false, { command: newRequest.command, queueLength: getAllPersistedRequests().length, }); - return; + return persistencePromise; } + // Defer flush() onto persistencePromise so the XHR cannot fire before the Onyx disk + // commit lands. If the process is killed between fire and commit, the request would be + // lost from storage on next launch. The in-memory queue is already updated synchronously + // above, so callers checking getAll()/getLength() right after push() still see the new state. if (isSequentialQueueRunning) { Log.info('[SequentialQueue] Queue is running. Will flush when the current request is finished.', false, { command: newRequest.command, }); - isReadyPromise.then(() => { - Log.info('[SequentialQueue] isReadyPromise resolved, flushing queue', false, { - command: newRequest.command, + persistencePromise + .then(() => isReadyPromise) + .then(() => { + Log.info('[SequentialQueue] isReadyPromise resolved, flushing queue', false, { + command: newRequest.command, + }); + flush(true); }); - flush(true); - }); - return; + return persistencePromise; } Log.info('[SequentialQueue] Queue is not running. Flushing the queue.', false, { command: newRequest.command, }); - flush(true); + persistencePromise.then(() => flush(true)); + return persistencePromise; } function getCurrentRequest(): Promise { diff --git a/tests/unit/SequentialQueueTest.ts b/tests/unit/SequentialQueueTest.ts index b49478391eaa..e90d64e542bb 100644 --- a/tests/unit/SequentialQueueTest.ts +++ b/tests/unit/SequentialQueueTest.ts @@ -36,29 +36,30 @@ describe('SequentialQueue', () => { expect(getLength()).toBe(2); }); - it('should push two requests with conflict resolution and replace', () => { - SequentialQueue.push(request); - const requestWithConflictResolution: Request = { - command: 'ReconnectApp', - data: {accountID: 56789}, - checkAndFixConflictingRequest: (persistedRequests) => { - // should be one instance of ReconnectApp, get the index to replace it later - const index = persistedRequests.findIndex((r) => r.command === 'ReconnectApp'); - if (index === -1) { - return {conflictAction: {type: 'push'}}; - } - - return { - conflictAction: {type: 'replace', index}, - }; - }, - }; - SequentialQueue.push(requestWithConflictResolution); - expect(getLength()).toBe(1); - // We know there is only one request and it's ongoing. - // We can get it and verify that the ongoing request is the second one. - const ongoingRequest = getOngoingRequest(); - expect(ongoingRequest?.data?.accountID).toBe(56789); + it('should push two requests with conflict resolution and replace', async () => { + // Pause the queue so `process()` does not consume the first request before + // the conflict resolver runs. Under persist-before-fire `push()` is async, + // so we await both pushes and then assert on the on-disk queue directly. + SequentialQueue.pause(); + try { + await SequentialQueue.push(request); + const requestWithConflictResolution: Request = { + command: 'ReconnectApp', + data: {accountID: 56789}, + checkAndFixConflictingRequest: (persistedRequests) => { + const index = persistedRequests.findIndex((r) => r.command === 'ReconnectApp'); + if (index === -1) { + return {conflictAction: {type: 'push'}}; + } + return {conflictAction: {type: 'replace', index}}; + }, + }; + await SequentialQueue.push(requestWithConflictResolution); + expect(getLength()).toBe(1); + expect(getAll().at(0)?.data?.accountID).toBe(56789); + } finally { + SequentialQueue.unpause(); + } }); it('should push two requests with conflict resolution and push', () => { @@ -88,105 +89,112 @@ describe('SequentialQueue', () => { }); it('should add a new request even if a similar one is ongoing', async () => { - // .push at the end flush the queue - SequentialQueue.push(request); + // Pause fetch so the first request lands as `ongoingRequest` but never completes. + // The conflict checker on push 2 inspects the persisted queue (which excludes the + // ongoing request), so it cannot find a 'ReconnectApp' to replace and falls back + // to 'push'. The new request is therefore added to the queue. + const mockedFetch = global.fetch as ReturnType & {pause: () => void; resume: () => Promise}; + mockedFetch.pause(); + try { + await SequentialQueue.push(request); + await waitForBatchedUpdates(); + expect(getOngoingRequest()?.command).toBe('ReconnectApp'); + + const requestWithConflictResolution: Request = { + command: 'ReconnectApp', + data: {accountID: 56789}, + checkAndFixConflictingRequest: (persistedRequests) => { + const index = persistedRequests.findIndex((r) => r.command === 'ReconnectApp'); + if (index === -1) { + return {conflictAction: {type: 'push'}}; + } + return {conflictAction: {type: 'replace', index}}; + }, + }; - // wait for Onyx.connect execute the callback and start processing the queue - await Promise.resolve(); + await SequentialQueue.push(requestWithConflictResolution); - const requestWithConflictResolution: Request = { - command: 'ReconnectApp', - data: {accountID: 56789}, - checkAndFixConflictingRequest: (persistedRequests) => { - // should be one instance of ReconnectApp, get the index to replace it later + // The new request is in the persisted queue with the expected accountID. + expect(getAll().some((r) => r.data?.accountID === 56789)).toBe(true); + } finally { + await mockedFetch.resume(); + } + }); + + it('should replace request request in queue while a similar one is ongoing', async () => { + const mockedFetch = global.fetch as ReturnType & {pause: () => void; resume: () => Promise}; + mockedFetch.pause(); + try { + await SequentialQueue.push(request); + await waitForBatchedUpdates(); + expect(getOngoingRequest()?.command).toBe('ReconnectApp'); + + const conflictResolver = (persistedRequests: Array>): ConflictActionData => { const index = persistedRequests.findIndex((r) => r.command === 'ReconnectApp'); if (index === -1) { return {conflictAction: {type: 'push'}}; } - - return { - conflictAction: {type: 'replace', index}, - }; - }, - }; - - SequentialQueue.push(requestWithConflictResolution); - - const ongoingRequest = getOngoingRequest(); - expect(ongoingRequest?.data?.accountID).toBe(56789); - }); - - it('should replace request request in queue while a similar one is ongoing', async () => { - // .push at the end flush the queue - SequentialQueue.push(request); - - // wait for Onyx.connect execute the callback and start processing the queue - await Promise.resolve(); - - const conflictResolver = (persistedRequests: Array>): ConflictActionData => { - // should be one instance of ReconnectApp, get the index to replace it later - const index = persistedRequests.findIndex((r) => r.command === 'ReconnectApp'); - if (index === -1) { - return {conflictAction: {type: 'push'}}; - } - - return { - conflictAction: {type: 'replace', index}, + return {conflictAction: {type: 'replace', index}}; }; - }; - const requestWithConflictResolution: Request = { - command: 'ReconnectApp', - data: {accountID: 56789}, - checkAndFixConflictingRequest: conflictResolver, - }; + const requestWithConflictResolution: Request = { + command: 'ReconnectApp', + data: {accountID: 56789}, + checkAndFixConflictingRequest: conflictResolver, + }; - const requestWithConflictResolution2: Request = { - command: 'ReconnectApp', - data: {accountID: 56789}, - checkAndFixConflictingRequest: conflictResolver, - }; + const requestWithConflictResolution2: Request = { + command: 'ReconnectApp', + data: {accountID: 56789}, + checkAndFixConflictingRequest: conflictResolver, + }; - SequentialQueue.push(requestWithConflictResolution); - SequentialQueue.push(requestWithConflictResolution2); + // First conflict push: queue is empty (ongoing not in queue) → push action → queue=[r2]. + // Second conflict push: queue=[r2] → replace at 0 → queue=[r3]. + // Total in-flight items: ongoing + queue = 2. + await SequentialQueue.push(requestWithConflictResolution); + await SequentialQueue.push(requestWithConflictResolution2); - expect(getLength()).toBe(2); + expect(getLength()).toBe(2); + } finally { + await mockedFetch.resume(); + } }); - it('should replace request request in queue while a similar one is ongoing and keep the same index', () => { - SequentialQueue.push({command: 'OpenReport'}); - SequentialQueue.push(request); - - const requestWithConflictResolution: Request = { - command: 'ReconnectApp', - data: {accountID: 56789}, - checkAndFixConflictingRequest: (persistedRequests) => { - // should be one instance of ReconnectApp, get the index to replace it later - const index = persistedRequests.findIndex((r) => r.command === 'ReconnectApp'); - if (index === -1) { - return {conflictAction: {type: 'push'}}; - } - - return { - conflictAction: {type: 'replace', index}, - }; - }, - }; - - SequentialQueue.push(requestWithConflictResolution); - SequentialQueue.push({command: 'AddComment'}); - SequentialQueue.push({command: 'OpenReport'}); - - expect(getLength()).toBe(4); - const persistedRequests = getAll(); - const ongoingRequest = getOngoingRequest(); + it('should replace request request in queue while a similar one is ongoing and keep the same index', async () => { + const mockedFetch = global.fetch as ReturnType & {pause: () => void; resume: () => Promise}; + mockedFetch.pause(); + try { + // First push moves into `ongoingRequest`; subsequent pushes stack in the queue. + await SequentialQueue.push({command: 'OpenReport'}); + await waitForBatchedUpdates(); + expect(getOngoingRequest()?.command).toBe('OpenReport'); + + await SequentialQueue.push(request); + + const requestWithConflictResolution: Request = { + command: 'ReconnectApp', + data: {accountID: 56789}, + checkAndFixConflictingRequest: (persistedRequests) => { + const index = persistedRequests.findIndex((r) => r.command === 'ReconnectApp'); + if (index === -1) { + return {conflictAction: {type: 'push'}}; + } + return {conflictAction: {type: 'replace', index}}; + }, + }; - // The first OpenReport call is ongoing - expect(ongoingRequest?.command).toBe('OpenReport'); + await SequentialQueue.push(requestWithConflictResolution); + await SequentialQueue.push({command: 'AddComment'}); + await SequentialQueue.push({command: 'OpenReport'}); - // We know ReconnectApp is at index 0 in the queue now, so we can get it to verify - // that was replaced by the new request. - expect(persistedRequests.at(0)?.data?.accountID).toBe(56789); + expect(getLength()).toBe(4); + const persistedRequests = getAll(); + expect(getOngoingRequest()?.command).toBe('OpenReport'); + expect(persistedRequests.at(0)?.data?.accountID).toBe(56789); + } finally { + await mockedFetch.resume(); + } }); // need to test a rance condition between processing the next request and then pushing a new request with conflict resolver From ce33aa97492daf99fba7771fab1d3f0223c6c8e7 Mon Sep 17 00:00:00 2001 From: Adam Horodyski Date: Wed, 27 May 2026 14:46:22 +0200 Subject: [PATCH 028/435] SequentialQueue: async push() with idempotent isReadyPromise marker --- src/libs/Network/SequentialQueue.ts | 73 ++++++++++++++++------------- 1 file changed, 41 insertions(+), 32 deletions(-) diff --git a/src/libs/Network/SequentialQueue.ts b/src/libs/Network/SequentialQueue.ts index 503d354df5db..35e27716f8ab 100644 --- a/src/libs/Network/SequentialQueue.ts +++ b/src/libs/Network/SequentialQueue.ts @@ -44,13 +44,28 @@ type RequestError = Error & { status?: string; }; -let resolveIsReadyPromise: ((args?: unknown[]) => void) | undefined; -let isReadyPromise = new Promise((resolve) => { - resolveIsReadyPromise = resolve; -}); +let resolveIsReadyPromise: (() => void) | undefined; +let isReadyPromise: Promise = Promise.resolve(); +let isReadyPromisePending = false; -// Resolve the isReadyPromise immediately so that the queue starts working as soon as the page loads -resolveIsReadyPromise?.(); +/** + * Marks isReadyPromise as pending so any READ that consults waitForIdle() parks behind us. + * Idempotent: if already pending, no-op (avoids orphaning subscribers from prior pushes). + * Called from push()'s sync prelude before the first await, so READs on the next sync line + * see the pending promise. + */ +function setIsReadyPromisePending() { + if (isReadyPromisePending) { + return; + } + isReadyPromise = new Promise((resolve) => { + resolveIsReadyPromise = () => { + isReadyPromisePending = false; + resolve(); + }; + }); + isReadyPromisePending = true; +} let isSequentialQueueRunning = false; let currentRequestPromise: Promise | null = null; @@ -334,10 +349,9 @@ function flush(shouldResetPromise = true) { isSequentialQueueRunning = true; if (shouldResetPromise) { - // Reset the isReadyPromise so that the queue will be flushed as soon as the request is finished - isReadyPromise = new Promise((resolve) => { - resolveIsReadyPromise = resolve; - }); + // Mark isReadyPromise as pending so READs (waitForIdle) park behind us. + // Idempotent — safe if push() already marked it pending in its sync prelude. + setIsReadyPromisePending(); } // Ensure persistedRequests are read from storage before proceeding with the queue @@ -504,7 +518,7 @@ async function handleConflictActions(conflictAction: Confl } } -function push(newRequest: OnyxRequest): Promise { +async function push(newRequest: OnyxRequest): Promise { const currentRequests = getAllPersistedRequests(); Log.info('[SequentialQueue] push() called', false, { command: newRequest.command, @@ -531,39 +545,35 @@ function push(newRequest: OnyxRequest): Promise isReadyPromise) - .then(() => { - Log.info('[SequentialQueue] isReadyPromise resolved, flushing queue', false, { - command: newRequest.command, - }); - flush(true); - }); - return persistencePromise; + isReadyPromise.then(() => flush(false)); + return; } Log.info('[SequentialQueue] Queue is not running. Flushing the queue.', false, { command: newRequest.command, }); - persistencePromise.then(() => flush(true)); - return persistencePromise; + flush(false); } function getCurrentRequest(): Promise { @@ -588,10 +598,9 @@ function resetQueue(): void { isSequentialQueueRunning = false; currentRequestPromise = null; isQueuePaused = false; - isReadyPromise = new Promise((resolve) => { - resolveIsReadyPromise = resolve; - }); - resolveIsReadyPromise?.(); + isReadyPromise = Promise.resolve(); + isReadyPromisePending = false; + resolveIsReadyPromise = undefined; } export { From cfd043fd0b003d25e14b63c2a43602bd49c7ecb9 Mon Sep 17 00:00:00 2001 From: Alex Beaman Date: Wed, 27 May 2026 14:17:49 -0600 Subject: [PATCH 029/435] Add IOURequestStepVendor RHP + nav wiring (Phase 3) New vendor selector RHP that lists QBO vendors with name search and writes the user pick via the Phase 1 updateMoneyRequestVendor action (isManuallySet hardcoded true). Wires the screen, route, navigation type, linking config, modal-stack registration, withWritableReportOrNotFound + withFullTransactionOrNotFound union members, and adds the common.vendor translation in all 10 supported locales. --- src/ROUTES.ts | 10 ++ src/SCREENS.ts | 1 + src/languages/de.ts | 1 + src/languages/en.ts | 1 + src/languages/es.ts | 1 + src/languages/fr.ts | 1 + src/languages/it.ts | 1 + src/languages/ja.ts | 1 + src/languages/nl.ts | 1 + src/languages/pl.ts | 1 + src/languages/pt-BR.ts | 1 + src/languages/zh-hans.ts | 1 + .../ModalStackNavigators/index.tsx | 1 + src/libs/Navigation/linkingConfig/config.ts | 1 + src/libs/Navigation/types.ts | 9 ++ .../iou/request/step/IOURequestStepVendor.tsx | 129 ++++++++++++++++++ .../step/withFullTransactionOrNotFound.tsx | 1 + .../step/withWritableReportOrNotFound.tsx | 1 + 18 files changed, 163 insertions(+) create mode 100644 src/pages/iou/request/step/IOURequestStepVendor.tsx diff --git a/src/ROUTES.ts b/src/ROUTES.ts index cf6f8f95f7a5..f8ce0e5aee4d 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -1636,6 +1636,16 @@ const ROUTES = { return getUrlWithBackToParam(`${action as string}/${iouType as string}/category/${transactionID}/${reportID}${reportActionID ? `/${reportActionID}` : ''}`, backTo); }, }, + MONEY_REQUEST_STEP_VENDOR: { + route: ':action/:iouType/vendor/:transactionID/:reportID/:reportActionID?', + getRoute: (action: IOUAction, iouType: IOUType, transactionID: string | undefined, reportID: string | undefined, backTo = '', reportActionID?: string) => { + if (!transactionID || !reportID) { + Log.warn('Invalid transactionID or reportID is used to build the MONEY_REQUEST_STEP_VENDOR route'); + } + + return getUrlWithBackToParam(`${action as string}/${iouType as string}/vendor/${transactionID}/${reportID}${reportActionID ? `/${reportActionID}` : ''}`, backTo); + }, + }, MONEY_REQUEST_ATTENDEE: { route: ':action/:iouType/attendees/:transactionID/:reportID', getRoute: (action: IOUAction, iouType: IOUType, transactionID: string | undefined, reportID: string | undefined, backTo = '') => { diff --git a/src/SCREENS.ts b/src/SCREENS.ts index 66956e3afc50..264c5861356b 100644 --- a/src/SCREENS.ts +++ b/src/SCREENS.ts @@ -386,6 +386,7 @@ const SCREENS = { STEP_WAYPOINT: 'Money_Request_Step_Waypoint', STEP_TAX_AMOUNT: 'Money_Request_Step_Tax_Amount', STEP_TAX_RATE: 'Money_Request_Step_Tax_Rate', + STEP_VENDOR: 'Money_Request_Step_Vendor', RECEIPT_VIEW: 'Money_Request_Receipt_View', STEP_SEND_FROM: 'Money_Request_Step_Send_From', STEP_COMPANY_INFO: 'Money_Request_Step_Company_Info', diff --git a/src/languages/de.ts b/src/languages/de.ts index a6cf5c09960f..8f31b9739542 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -314,6 +314,7 @@ const translations: TranslationDeepObject = { merchant: 'Händler', change: 'Ändern', category: 'Kategorie', + vendor: 'Anbieter', report: 'Bericht', billable: 'Abrechenbar', nonBillable: 'Nicht abrechenbar', diff --git a/src/languages/en.ts b/src/languages/en.ts index 0b50e1b7b55e..a6545036592b 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -328,6 +328,7 @@ const translations = { merchant: 'Merchant', change: 'Change', category: 'Category', + vendor: 'Vendor', report: 'Report', billable: 'Billable', nonBillable: 'Non-billable', diff --git a/src/languages/es.ts b/src/languages/es.ts index f84ec9f6a162..b8c4a13a52a0 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -270,6 +270,7 @@ const translations: TranslationDeepObject = { merchant: 'Comerciante', change: 'Cambio', category: 'Categoría', + vendor: 'Proveedor', report: 'Informe', billable: 'Facturable', nonBillable: 'No facturable', diff --git a/src/languages/fr.ts b/src/languages/fr.ts index eb8b2d3b96f3..1fabec4c19e0 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -314,6 +314,7 @@ const translations: TranslationDeepObject = { merchant: 'Commerçant', change: 'Modifier', category: 'Catégorie', + vendor: 'Fournisseur', report: 'Note de frais', billable: 'Facturable', nonBillable: 'Non refacturable', diff --git a/src/languages/it.ts b/src/languages/it.ts index 8b55789d977a..fb9cd6d330df 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -314,6 +314,7 @@ const translations: TranslationDeepObject = { merchant: 'Esercente', change: 'Modifica', category: 'Categoria', + vendor: 'Fornitore', report: 'Report', billable: 'Fatturabile', nonBillable: 'Non fatturabile', diff --git a/src/languages/ja.ts b/src/languages/ja.ts index 7dadd52e6801..141707562870 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -314,6 +314,7 @@ const translations: TranslationDeepObject = { merchant: '加盟店', change: '変更', category: 'カテゴリ', + vendor: 'ベンダー', report: 'レポート', billable: '請求可能', nonBillable: '請求不可', diff --git a/src/languages/nl.ts b/src/languages/nl.ts index 9a7bb8d0ffb0..6d0737fb7a0d 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -314,6 +314,7 @@ const translations: TranslationDeepObject = { merchant: 'Handelaar', change: 'Wijzigen', category: 'Categorie', + vendor: 'Leverancier', report: 'Rapport', billable: 'Factureerbaar', nonBillable: 'Niet-factureerbaar', diff --git a/src/languages/pl.ts b/src/languages/pl.ts index f4011e201ee0..8faecbccac51 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -314,6 +314,7 @@ const translations: TranslationDeepObject = { merchant: 'Sprzedawca', change: 'Zmień', category: 'Kategoria', + vendor: 'Dostawca', report: 'Raport', billable: 'Fakturowalne', nonBillable: 'Nierozliczalne', diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index da031573508b..67867a639057 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -314,6 +314,7 @@ const translations: TranslationDeepObject = { merchant: 'Estabelecimento', change: 'Alterar', category: 'Categoria', + vendor: 'Fornecedor', report: 'Relatório', billable: 'Faturável', nonBillable: 'Não faturável', diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index ea9c4e854831..df924a6431e6 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -314,6 +314,7 @@ const translations: TranslationDeepObject = { merchant: '商户', change: '更改', category: '类别', + vendor: '供应商', report: '报表', billable: '可计费', nonBillable: '不可计费', diff --git a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx index 24eb086ebc38..a1c85a4195c7 100644 --- a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx +++ b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx @@ -211,6 +211,7 @@ const MoneyRequestModalStackNavigator = createModalStackNavigator require('../../../../pages/iou/request/step/IOURequestStepTaxRatePage').default, [SCREENS.MONEY_REQUEST.STEP_CATEGORY]: () => require('../../../../pages/iou/request/step/IOURequestStepCategory').default, [SCREENS.MONEY_REQUEST.STEP_CATEGORY_CREATE]: () => require('../../../../pages/iou/request/step/IOURequestStepCategoryCreate').default, + [SCREENS.MONEY_REQUEST.STEP_VENDOR]: () => require('../../../../pages/iou/request/step/IOURequestStepVendor').default, [SCREENS.MONEY_REQUEST.STEP_DATE]: () => require('../../../../pages/iou/request/step/IOURequestStepDate').default, [SCREENS.MONEY_REQUEST.STEP_DESCRIPTION]: () => require('../../../../pages/iou/request/step/IOURequestStepDescription').default, [SCREENS.MONEY_REQUEST.STEP_DISTANCE]: () => require('../../../../pages/iou/request/step/IOURequestStepDistance').default, diff --git a/src/libs/Navigation/linkingConfig/config.ts b/src/libs/Navigation/linkingConfig/config.ts index 4ff99bc12147..f70dd2e6b460 100644 --- a/src/libs/Navigation/linkingConfig/config.ts +++ b/src/libs/Navigation/linkingConfig/config.ts @@ -1744,6 +1744,7 @@ const config: LinkingOptions['config'] = { [SCREENS.MONEY_REQUEST.STEP_AMOUNT]: ROUTES.MONEY_REQUEST_STEP_AMOUNT.route, [SCREENS.MONEY_REQUEST.STEP_CATEGORY]: ROUTES.MONEY_REQUEST_STEP_CATEGORY.route, [SCREENS.MONEY_REQUEST.STEP_CATEGORY_CREATE]: ROUTES.MONEY_REQUEST_STEP_CATEGORY_CREATE.route, + [SCREENS.MONEY_REQUEST.STEP_VENDOR]: ROUTES.MONEY_REQUEST_STEP_VENDOR.route, [SCREENS.MONEY_REQUEST.STEP_CONFIRMATION]: ROUTES.MONEY_REQUEST_STEP_CONFIRMATION.route, [SCREENS.MONEY_REQUEST.STEP_CONFIRMATION_VERIFY_ACCOUNT]: ROUTES.MONEY_REQUEST_STEP_CONFIRMATION_VERIFY_ACCOUNT.route, [SCREENS.MONEY_REQUEST.STEP_DATE]: ROUTES.MONEY_REQUEST_STEP_DATE.route, diff --git a/src/libs/Navigation/types.ts b/src/libs/Navigation/types.ts index 4957871c9dc0..8ccf18e4ea58 100644 --- a/src/libs/Navigation/types.ts +++ b/src/libs/Navigation/types.ts @@ -1884,6 +1884,15 @@ type MoneyRequestNavigatorParamList = { // eslint-disable-next-line no-restricted-syntax -- `backTo` usages in this file are legacy. Do not add new `backTo` params to screens. See contributingGuides/NAVIGATION.md backTo: Routes; }; + [SCREENS.MONEY_REQUEST.STEP_VENDOR]: { + action: IOUAction; + iouType: Exclude; + transactionID: string; + reportActionID: string; + reportID: string; + // eslint-disable-next-line no-restricted-syntax -- `backTo` usages in this file are legacy. Do not add new `backTo` params to screens. See contributingGuides/NAVIGATION.md + backTo: Routes; + }; [SCREENS.MONEY_REQUEST.STEP_CATEGORY_CREATE]: { action: IOUAction; iouType: Exclude; diff --git a/src/pages/iou/request/step/IOURequestStepVendor.tsx b/src/pages/iou/request/step/IOURequestStepVendor.tsx new file mode 100644 index 000000000000..94dd9d49971e --- /dev/null +++ b/src/pages/iou/request/step/IOURequestStepVendor.tsx @@ -0,0 +1,129 @@ +import React, {useMemo, useState} from 'react'; +import BlockingView from '@components/BlockingViews/BlockingView'; +import SelectionList from '@components/SelectionList'; +import SingleSelectListItem from '@components/SelectionList/ListItem/SingleSelectListItem'; +import type {ListItem} from '@components/SelectionList/types'; +import {useMemoizedLazyIllustrations} from '@hooks/useLazyAsset'; +import useLocalize from '@hooks/useLocalize'; +import usePermissions from '@hooks/usePermissions'; +import usePolicyForTransaction from '@hooks/usePolicyForTransaction'; +import useShowNotFoundPageInIOUStep from '@hooks/useShowNotFoundPageInIOUStep'; +import useThemeStyles from '@hooks/useThemeStyles'; +import {updateMoneyRequestVendor} from '@libs/actions/IOU/UpdateMoneyRequest'; +import Navigation from '@libs/Navigation/Navigation'; +import {getQBOVendors, hasVendorFeature} from '@libs/PolicyUtils'; +import {isPerDiemRequest} from '@libs/TransactionUtils'; +import variables from '@styles/variables'; +import CONST from '@src/CONST'; +import type SCREENS from '@src/SCREENS'; +import StepScreenWrapper from './StepScreenWrapper'; +import type {WithFullTransactionOrNotFoundProps} from './withFullTransactionOrNotFound'; +import withFullTransactionOrNotFound from './withFullTransactionOrNotFound'; +import type {WithWritableReportOrNotFoundProps} from './withWritableReportOrNotFound'; +import withWritableReportOrNotFound from './withWritableReportOrNotFound'; + +type VendorListItem = ListItem & { + value: string; +}; + +type IOURequestStepVendorProps = WithWritableReportOrNotFoundProps & WithFullTransactionOrNotFoundProps; + +function IOURequestStepVendor({ + report, + route: { + params: {action, iouType, transactionID, reportActionID, backTo}, + }, + transaction, +}: IOURequestStepVendorProps) { + const styles = useThemeStyles(); + const {translate} = useLocalize(); + const {isBetaEnabled} = usePermissions(); + const illustrations = useMemoizedLazyIllustrations(['Telescope']); + const [searchValue, setSearchValue] = useState(''); + + const {policy} = usePolicyForTransaction({ + transaction, + reportPolicyID: report?.policyID, + action, + iouType, + isPerDiemRequest: isPerDiemRequest(transaction), + }); + + const isFeatureAvailable = hasVendorFeature(policy, isBetaEnabled(CONST.BETAS.VENDOR_MATCHING)); + const vendors = useMemo(() => getQBOVendors(policy), [policy]); + const currentVendorID = transaction?.comment?.vendor?.externalID; + + const data: VendorListItem[] = useMemo(() => { + const trimmed = searchValue.trim().toLowerCase(); + return vendors + .filter((vendor) => !trimmed || vendor.name.toLowerCase().includes(trimmed)) + .map((vendor) => ({ + value: vendor.id, + text: vendor.name, + keyForList: vendor.id, + isSelected: vendor.id === currentVendorID, + searchText: vendor.name, + })); + }, [vendors, currentVendorID, searchValue]); + + const shouldShowNotFoundPage = useShowNotFoundPageInIOUStep(action, iouType, reportActionID, report, transaction) || !isFeatureAvailable; + + const navigateBack = () => { + Navigation.goBack(backTo); + }; + + const selectVendor = (item: VendorListItem) => { + if (item.value !== currentVendorID) { + updateMoneyRequestVendor(transactionID, item.value, transaction); + } + navigateBack(); + }; + + const headerMessage = searchValue && data.length === 0 ? translate('common.noResultsFound') : ''; + + const listEmptyContent = useMemo( + () => + vendors.length === 0 ? ( + + ) : null, + [vendors.length, illustrations.Telescope, translate, styles.pb10], + ); + + return ( + + item.isSelected)?.keyForList} + ListItem={SingleSelectListItem} + shouldShowLoadingPlaceholder={!policy} + listEmptyContent={listEmptyContent} + shouldSingleExecuteRowSelect + /> + + ); +} + +IOURequestStepVendor.displayName = 'IOURequestStepVendor'; + +export default withWritableReportOrNotFound(withFullTransactionOrNotFound(IOURequestStepVendor)); diff --git a/src/pages/iou/request/step/withFullTransactionOrNotFound.tsx b/src/pages/iou/request/step/withFullTransactionOrNotFound.tsx index f37b4127d942..df3be6584abc 100644 --- a/src/pages/iou/request/step/withFullTransactionOrNotFound.tsx +++ b/src/pages/iou/request/step/withFullTransactionOrNotFound.tsx @@ -39,6 +39,7 @@ type MoneyRequestRouteName = | typeof SCREENS.MONEY_REQUEST.STEP_DISTANCE_RATE | typeof SCREENS.MONEY_REQUEST.STEP_CONFIRMATION | typeof SCREENS.MONEY_REQUEST.STEP_CATEGORY + | typeof SCREENS.MONEY_REQUEST.STEP_VENDOR | typeof SCREENS.MONEY_REQUEST.STEP_TAX_RATE | typeof SCREENS.MONEY_REQUEST.STEP_SCAN | typeof SCREENS.MONEY_REQUEST.STEP_SEND_FROM diff --git a/src/pages/iou/request/step/withWritableReportOrNotFound.tsx b/src/pages/iou/request/step/withWritableReportOrNotFound.tsx index 5eb13936ed83..9b25511beb50 100644 --- a/src/pages/iou/request/step/withWritableReportOrNotFound.tsx +++ b/src/pages/iou/request/step/withWritableReportOrNotFound.tsx @@ -29,6 +29,7 @@ type MoneyRequestRouteName = | typeof SCREENS.MONEY_REQUEST.STEP_DESCRIPTION | typeof SCREENS.MONEY_REQUEST.STEP_DATE | typeof SCREENS.MONEY_REQUEST.STEP_CATEGORY + | typeof SCREENS.MONEY_REQUEST.STEP_VENDOR | typeof SCREENS.MONEY_REQUEST.STEP_DISTANCE_RATE | typeof SCREENS.MONEY_REQUEST.STEP_CONFIRMATION | typeof SCREENS.MONEY_REQUEST.STEP_TAX_RATE From 85ccf233532f6622016cfdb7c81d6ec1e4e09a5c Mon Sep 17 00:00:00 2001 From: Alex Beaman Date: Wed, 27 May 2026 14:19:56 -0600 Subject: [PATCH 030/435] Add Vendor row on MoneyRequestView (Phase 3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Surfaces the current expense vendor below the Category row when the workspace has the vendorMatching beta + QBO Credit/Debit-card export config AND the expense is non-reimbursable. Tapping opens the new MONEY_REQUEST_STEP_VENDOR RHP. The inactiveVendor violation is surfaced via getErrorForField('vendor') using the Phase 1 useViolations mapping (inactiveVendor → 'vendor' field). --- .../ReportActionItem/MoneyRequestView.tsx | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/src/components/ReportActionItem/MoneyRequestView.tsx b/src/components/ReportActionItem/MoneyRequestView.tsx index 0e24f5e95356..e3015041af29 100644 --- a/src/components/ReportActionItem/MoneyRequestView.tsx +++ b/src/components/ReportActionItem/MoneyRequestView.tsx @@ -58,8 +58,10 @@ import { getLengthOfTag, getPerDiemCustomUnit, getPolicyByCustomUnitID, + getQBOVendorByID, getTagLists, hasDependentTags as hasDependentTagsPolicyUtils, + hasVendorFeature, isAttendeeTrackingEnabled, isPolicyAccessible, isTaxTrackingEnabled, @@ -450,6 +452,10 @@ function MoneyRequestView({ }); const shouldShowAttendees = shouldShowAttendeesTransactionUtils(iouType, policy); + const transactionVendor = transaction?.comment?.vendor; + const transactionVendorName = getQBOVendorByID(policy, transactionVendor?.externalID)?.name ?? ''; + const shouldShowVendor = hasVendorFeature(policy, isBetaEnabled(CONST.BETAS.VENDOR_MATCHING)) && !(updatedTransaction?.reimbursable ?? !!transactionReimbursable); + const tripID = getTripIDFromTransactionParentReportID(parentReport?.parentReportID); const shouldShowViewTripDetails = hasReservationList(transaction) && !!tripID; @@ -1120,6 +1126,34 @@ function MoneyRequestView({ /> )} + {shouldShowVendor && ( + + { + if (!transactionThreadReport?.reportID) { + return; + } + Navigation.navigate( + ROUTES.MONEY_REQUEST_STEP_VENDOR.getRoute( + CONST.IOU.ACTION.EDIT, + iouType, + transaction.transactionID, + transactionThreadReport.reportID, + Navigation.getActiveRoute(), + ), + ); + }} + brickRoadIndicator={getErrorForField('vendor') ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined} + errorText={getErrorForField('vendor')} + /> + + )} {shouldShowTag && tagList} {!!shouldShowCard && ( From 630162241b07e7c87ac93096d1c9793fc0260595 Mon Sep 17 00:00:00 2001 From: Alex Beaman Date: Wed, 27 May 2026 14:24:28 -0600 Subject: [PATCH 031/435] Add ConciergeAutoMatchVendor action rendering (Phase 3) Wires the App side of the Phase 1 Auth-emitted CONCIERGEAUTOMATCHVENDOR report action: new CONST entry, OriginalMessage type (vendorName + reasoning), renderer using ReportActionItemMessageWithExplain (Explain link surfaces automatically via hasReasoning check), and translations across all 10 supported locales. --- src/CONST/index.ts | 1 + src/languages/de.ts | 1 + src/languages/en.ts | 1 + src/languages/es.ts | 1 + src/languages/fr.ts | 1 + src/languages/it.ts | 1 + src/languages/ja.ts | 1 + src/languages/nl.ts | 1 + src/languages/pl.ts | 1 + src/languages/pt-BR.ts | 1 + src/languages/zh-hans.ts | 1 + .../actionContents/ActionContentRouter.tsx | 9 +++++ .../ConciergeAutoMatchVendorContent.tsx | 37 +++++++++++++++++++ src/types/onyx/OriginalMessage.ts | 11 ++++++ 14 files changed, 68 insertions(+) create mode 100644 src/pages/inbox/report/actionContents/ConciergeAutoMatchVendorContent.tsx diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 05b4259c0b2f..02ffba7b3b89 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -1585,6 +1585,7 @@ const CONST = { MARK_REIMBURSED_FROM_INTEGRATION: 'ACTIONMARKEDREIMBURSEDFROMINTEGRATION', // OldDot Action MERGED_WITH_CASH_TRANSACTION: 'MERGEDWITHCASHTRANSACTION', MODIFIED_EXPENSE: 'MODIFIEDEXPENSE', + CONCIERGE_AUTO_MATCH_VENDOR: 'CONCIERGEAUTOMATCHVENDOR', MOVED: 'MOVED', MOVED_TRANSACTION: 'MOVEDTRANSACTION', UNREPORTED_TRANSACTION: 'UNREPORTEDTRANSACTION', diff --git a/src/languages/de.ts b/src/languages/de.ts index 8f31b9739542..e9dbd7028368 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -1657,6 +1657,7 @@ const translations: TranslationDeepObject = { }, correctRateError: 'Beheben Sie den Kursfehler und versuchen Sie es erneut.', AskToExplain: `. Erklären`, + conciergeAutoMatchedVendor: ({vendorName}: {vendorName: string}) => `Concierge hat diese Ausgabe ${vendorName} zugeordnet`, duplicateNonDefaultWorkspacePerDiemError: 'Sie können Per-Diem-Ausgaben nicht über mehrere Workspaces hinweg duplizieren, da sich die Sätze zwischen den Workspaces unterscheiden können.', rulesModifiedFields: { diff --git a/src/languages/en.ts b/src/languages/en.ts index a6545036592b..c8f362962950 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -1711,6 +1711,7 @@ const translations = { }, correctRateError: 'Fix the rate error and try again.', AskToExplain: `. Explain`, + conciergeAutoMatchedVendor: ({vendorName}: {vendorName: string}) => `Concierge matched this expense to ${vendorName}`, rulesModifiedFields: { reimbursable: (value: boolean) => (value ? 'marked the expense as "reimbursable"' : 'marked the expense as "non-reimbursable"'), billable: (value: boolean) => (value ? 'marked the expense as "billable"' : 'marked the expense as "non-billable"'), diff --git a/src/languages/es.ts b/src/languages/es.ts index b8c4a13a52a0..6257665a4a84 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -1620,6 +1620,7 @@ const translations: TranslationDeepObject = { }, correctRateError: 'Corrige el error de la tasa y vuelve a intentarlo.', AskToExplain: `. Explicar`, + conciergeAutoMatchedVendor: ({vendorName}: {vendorName: string}) => `Concierge asoció este gasto con ${vendorName}`, rulesModifiedFields: { reimbursable: (value: boolean) => (value ? 'marcó el gasto como "reembolsable"' : 'marcó el gasto como "no reembolsable"'), billable: (value: boolean) => (value ? 'marcó el gasto como "facturable"' : 'marcó el gasto como "no facturable"'), diff --git a/src/languages/fr.ts b/src/languages/fr.ts index 1fabec4c19e0..5d630887a5b3 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -1663,6 +1663,7 @@ const translations: TranslationDeepObject = { }, correctRateError: 'Corrigez l’erreur de taux et réessayez.', AskToExplain: `. Expliquer`, + conciergeAutoMatchedVendor: ({vendorName}: {vendorName: string}) => `Concierge a associé cette dépense au fournisseur ${vendorName}`, duplicateNonDefaultWorkspacePerDiemError: 'Vous ne pouvez pas dupliquer les indemnités journalières entre plusieurs espaces de travail, car les taux peuvent différer d’un espace de travail à l’autre.', rulesModifiedFields: { diff --git a/src/languages/it.ts b/src/languages/it.ts index fb9cd6d330df..f4336fa77d4c 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -1656,6 +1656,7 @@ const translations: TranslationDeepObject = { }, correctRateError: "Correggi l'errore di tariffa e riprova.", AskToExplain: `. Spiega`, + conciergeAutoMatchedVendor: ({vendorName}: {vendorName: string}) => `Concierge ha associato questa spesa al fornitore ${vendorName}`, duplicateNonDefaultWorkspacePerDiemError: 'Non puoi duplicare le spese di diaria tra diversi spazi di lavoro perché le tariffe potrebbero essere diverse tra gli spazi di lavoro.', rulesModifiedFields: { reimbursable: (value: boolean) => (value ? 'ha contrassegnato la spesa come "rimborsabile"' : 'ha contrassegnato la spesa come "non rimborsabile"'), diff --git a/src/languages/ja.ts b/src/languages/ja.ts index 141707562870..ba954497372f 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -1637,6 +1637,7 @@ const translations: TranslationDeepObject = { }, correctRateError: 'レートのエラーを修正して、もう一度お試しください。', AskToExplain: `・説明`, + conciergeAutoMatchedVendor: ({vendorName}: {vendorName: string}) => `Concierge がこの経費を ${vendorName} に一致させました`, duplicateNonDefaultWorkspacePerDiemError: 'ワークスペースごとに日当レートが異なる場合があるため、日当経費をワークスペース間で複製することはできません。', rulesModifiedFields: { reimbursable: (value: boolean) => (value ? '経費を「精算対象」に指定しました' : '経費を「精算対象外」にマークしました'), diff --git a/src/languages/nl.ts b/src/languages/nl.ts index 6d0737fb7a0d..2d4a7ef5ab32 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -1652,6 +1652,7 @@ const translations: TranslationDeepObject = { }, correctRateError: 'Los de tarieffout op en probeer het opnieuw.', AskToExplain: `. Uitleggen`, + conciergeAutoMatchedVendor: ({vendorName}: {vendorName: string}) => `Concierge heeft deze uitgave gekoppeld aan ${vendorName}`, duplicateNonDefaultWorkspacePerDiemError: 'Je kunt dagvergoedingen niet dupliceren tussen werkruimtes, omdat de tarieven per werkruimte kunnen verschillen.', rulesModifiedFields: { reimbursable: (value: boolean) => (value ? 'markeerde de uitgave als „terugbetaalbaar”' : 'heeft de uitgave als ‘niet-vergoedbaar’ gemarkeerd'), diff --git a/src/languages/pl.ts b/src/languages/pl.ts index 8faecbccac51..d4e705e51421 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -1651,6 +1651,7 @@ const translations: TranslationDeepObject = { }, correctRateError: 'Napraw błąd stawki i spróbuj ponownie.', AskToExplain: `. Wyjaśnij`, + conciergeAutoMatchedVendor: ({vendorName}: {vendorName: string}) => `Concierge dopasował ten wydatek do ${vendorName}`, duplicateNonDefaultWorkspacePerDiemError: 'Nie możesz duplikować wydatków z tytułu diet między przestrzeniami roboczymi, ponieważ stawki mogą się różnić między poszczególnymi przestrzeniami.', rulesModifiedFields: { diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index 67867a639057..d005d8705aab 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -1650,6 +1650,7 @@ const translations: TranslationDeepObject = { }, correctRateError: 'Corrija o erro de taxa e tente novamente.', AskToExplain: `. Explicar`, + conciergeAutoMatchedVendor: ({vendorName}: {vendorName: string}) => `Concierge associou esta despesa ao fornecedor ${vendorName}`, duplicateNonDefaultWorkspacePerDiemError: 'Você não pode duplicar despesas de diárias entre espaços de trabalho porque as tarifas podem variar entre eles.', rulesModifiedFields: { reimbursable: (value: boolean) => (value ? 'marcou a despesa como "reembolsável"' : 'marcou a despesa como “não reembolsável”'), diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index df924a6431e6..141c6e4c991e 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -1602,6 +1602,7 @@ const translations: TranslationDeepObject = { }, correctRateError: '修复费率错误后请重试。', AskToExplain: `。说明`, + conciergeAutoMatchedVendor: ({vendorName}: {vendorName: string}) => `Concierge 已将此支出匹配到${vendorName}`, duplicateNonDefaultWorkspacePerDiemError: '您无法在不同工作区之间复制每日津贴报销,因为各工作区的补贴标准可能不同。', rulesModifiedFields: { reimbursable: (value: boolean) => (value ? '将该报销单标记为“可报销”' : '将该报销单标记为“不可报销”'), diff --git a/src/pages/inbox/report/actionContents/ActionContentRouter.tsx b/src/pages/inbox/report/actionContents/ActionContentRouter.tsx index 5cab7d5f5f75..212520a4443b 100644 --- a/src/pages/inbox/report/actionContents/ActionContentRouter.tsx +++ b/src/pages/inbox/report/actionContents/ActionContentRouter.tsx @@ -49,6 +49,7 @@ import ApprovalFlowContent, {isApprovalFlowAction} from './ApprovalFlowContent'; import CardBrokenConnectionContent from './CardBrokenConnectionContent'; import ChatMessageContent from './ChatMessageContent'; import ChatTransactionPreview from './ChatTransactionPreview'; +import ConciergeAutoMatchVendorContent from './ConciergeAutoMatchVendorContent'; import ConfirmWhisperContent from './ConfirmWhisperContent'; import FraudAlertContent from './FraudAlertContent'; import IntegrationSyncFailedMessage from './IntegrationSyncFailedMessage'; @@ -256,6 +257,14 @@ function ActionContentRouter({ /> ); } + if (action.actionName === CONST.REPORT.ACTIONS.TYPE.CONCIERGE_AUTO_MATCH_VENDOR) { + return ( + + ); + } if (isApprovalFlowAction(action)) { return ( ; +}; + +function ConciergeAutoMatchVendorContent({action, originalReport}: ConciergeAutoMatchVendorContentProps) { + const {translate} = useLocalize(); + const [childReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${getNonEmptyStringOnyxID(action.childReportID)}`); + + const originalMessage = getOriginalMessage(action); + const vendorName = originalMessage && typeof originalMessage === 'object' && 'vendorName' in originalMessage ? (originalMessage.vendorName ?? '') : ''; + const message = translate('iou.conciergeAutoMatchedVendor', {vendorName}); + + return ( + + ); +} + +ConciergeAutoMatchVendorContent.displayName = 'ConciergeAutoMatchVendorContent'; + +export default ConciergeAutoMatchVendorContent; diff --git a/src/types/onyx/OriginalMessage.ts b/src/types/onyx/OriginalMessage.ts index 0bc0e4fb9975..ddcffb4d25c3 100644 --- a/src/types/onyx/OriginalMessage.ts +++ b/src/types/onyx/OriginalMessage.ts @@ -889,6 +889,15 @@ type OriginalMessageModifiedExpense = { reasoning?: string; }; +/** Model of `concierge auto match vendor` report action — emitted on the transaction thread when the PHP fuzzy matcher auto-matches a non-reimbursable expense to a QBO vendor. */ +type OriginalMessageConciergeAutoMatchVendor = { + /** Display name of the matched vendor */ + vendorName?: string; + + /** LLM-consumable explanation of why this vendor was matched — surfaced behind the "Explain" link */ + reasoning?: string; +}; + /** Policy rules modified fields */ type PolicyRulesModifiedFields = { /** The value that the merchant was changed to */ @@ -1527,6 +1536,7 @@ type OriginalMessageMap = { [CONST.REPORT.ACTIONS.TYPE.MARKED_REIMBURSED]: OriginalMessageMarkedReimbursed; [CONST.REPORT.ACTIONS.TYPE.MERGED_WITH_CASH_TRANSACTION]: never; [CONST.REPORT.ACTIONS.TYPE.MODIFIED_EXPENSE]: OriginalMessageModifiedExpense; + [CONST.REPORT.ACTIONS.TYPE.CONCIERGE_AUTO_MATCH_VENDOR]: OriginalMessageConciergeAutoMatchVendor; [CONST.REPORT.ACTIONS.TYPE.MOVED]: OriginalMessageMoved; [CONST.REPORT.ACTIONS.TYPE.MOVED_TRANSACTION]: OriginalMessageMovedTransaction; [CONST.REPORT.ACTIONS.TYPE.UNREPORTED_TRANSACTION]: OriginalMessageUnreportedTransaction; @@ -1610,6 +1620,7 @@ export type { OriginalMessageChangeLog, JoinWorkspaceResolution, OriginalMessageModifiedExpense, + OriginalMessageConciergeAutoMatchVendor, OriginalMessageExportIntegration, IssueNewCardOriginalMessage, OriginalMessageChangePolicy, From 0064c86f2f4e2027fb7a8be73f8bcdb656e148c7 Mon Sep 17 00:00:00 2001 From: Alex Beaman Date: Wed, 27 May 2026 14:27:33 -0600 Subject: [PATCH 032/435] Phase 3 cleanup: drop deprecated backTo from MONEY_REQUEST_STEP_VENDOR Removes getUrlWithBackToParam usage from the new route (the deprecated backTo pattern, per contributingGuides/NAVIGATION.md) and removes the unused CONST import in ConciergeAutoMatchVendorContent. Navigation back from the RHP just closes the modal, returning to the previous view. --- src/ROUTES.ts | 4 ++-- src/components/ReportActionItem/MoneyRequestView.tsx | 10 +--------- src/libs/Navigation/types.ts | 2 -- .../actionContents/ConciergeAutoMatchVendorContent.tsx | 1 - src/pages/iou/request/step/IOURequestStepVendor.tsx | 4 ++-- 5 files changed, 5 insertions(+), 16 deletions(-) diff --git a/src/ROUTES.ts b/src/ROUTES.ts index f8ce0e5aee4d..bd4b6283a66a 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -1638,12 +1638,12 @@ const ROUTES = { }, MONEY_REQUEST_STEP_VENDOR: { route: ':action/:iouType/vendor/:transactionID/:reportID/:reportActionID?', - getRoute: (action: IOUAction, iouType: IOUType, transactionID: string | undefined, reportID: string | undefined, backTo = '', reportActionID?: string) => { + getRoute: (action: IOUAction, iouType: IOUType, transactionID: string | undefined, reportID: string | undefined, reportActionID?: string) => { if (!transactionID || !reportID) { Log.warn('Invalid transactionID or reportID is used to build the MONEY_REQUEST_STEP_VENDOR route'); } - return getUrlWithBackToParam(`${action as string}/${iouType as string}/vendor/${transactionID}/${reportID}${reportActionID ? `/${reportActionID}` : ''}`, backTo); + return `${action as string}/${iouType as string}/vendor/${transactionID}/${reportID}${reportActionID ? `/${reportActionID}` : ''}` as const; }, }, MONEY_REQUEST_ATTENDEE: { diff --git a/src/components/ReportActionItem/MoneyRequestView.tsx b/src/components/ReportActionItem/MoneyRequestView.tsx index e3015041af29..5c616f2e7508 100644 --- a/src/components/ReportActionItem/MoneyRequestView.tsx +++ b/src/components/ReportActionItem/MoneyRequestView.tsx @@ -1139,15 +1139,7 @@ function MoneyRequestView({ if (!transactionThreadReport?.reportID) { return; } - Navigation.navigate( - ROUTES.MONEY_REQUEST_STEP_VENDOR.getRoute( - CONST.IOU.ACTION.EDIT, - iouType, - transaction.transactionID, - transactionThreadReport.reportID, - Navigation.getActiveRoute(), - ), - ); + Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_VENDOR.getRoute(CONST.IOU.ACTION.EDIT, iouType, transaction.transactionID, transactionThreadReport.reportID)); }} brickRoadIndicator={getErrorForField('vendor') ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined} errorText={getErrorForField('vendor')} diff --git a/src/libs/Navigation/types.ts b/src/libs/Navigation/types.ts index 8ccf18e4ea58..397deb024220 100644 --- a/src/libs/Navigation/types.ts +++ b/src/libs/Navigation/types.ts @@ -1890,8 +1890,6 @@ type MoneyRequestNavigatorParamList = { transactionID: string; reportActionID: string; reportID: string; - // eslint-disable-next-line no-restricted-syntax -- `backTo` usages in this file are legacy. Do not add new `backTo` params to screens. See contributingGuides/NAVIGATION.md - backTo: Routes; }; [SCREENS.MONEY_REQUEST.STEP_CATEGORY_CREATE]: { action: IOUAction; diff --git a/src/pages/inbox/report/actionContents/ConciergeAutoMatchVendorContent.tsx b/src/pages/inbox/report/actionContents/ConciergeAutoMatchVendorContent.tsx index b4e5f0861e0e..a5c9b2fe999e 100644 --- a/src/pages/inbox/report/actionContents/ConciergeAutoMatchVendorContent.tsx +++ b/src/pages/inbox/report/actionContents/ConciergeAutoMatchVendorContent.tsx @@ -5,7 +5,6 @@ import useOnyx from '@hooks/useOnyx'; import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; import {getOriginalMessage} from '@libs/ReportActionsUtils'; import ReportActionItemMessageWithExplain from '@pages/inbox/report/ReportActionItemMessageWithExplain'; -import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type {Report, ReportAction} from '@src/types/onyx'; diff --git a/src/pages/iou/request/step/IOURequestStepVendor.tsx b/src/pages/iou/request/step/IOURequestStepVendor.tsx index 94dd9d49971e..3742ee6e28c0 100644 --- a/src/pages/iou/request/step/IOURequestStepVendor.tsx +++ b/src/pages/iou/request/step/IOURequestStepVendor.tsx @@ -31,7 +31,7 @@ type IOURequestStepVendorProps = WithWritableReportOrNotFoundProps { - Navigation.goBack(backTo); + Navigation.goBack(); }; const selectVendor = (item: VendorListItem) => { From e35837c7da711ce10b8774ec0e1f6bde48c9ea70 Mon Sep 17 00:00:00 2001 From: "James Dean (via MelvinBot)" Date: Thu, 28 May 2026 01:16:15 +0000 Subject: [PATCH 033/435] Update validateAccount title copy Remove 'to continue using Expensify' from the validateAccount onboarding checklist title in both en.ts and es.ts. Co-authored-by: James Dean --- src/languages/en.ts | 2 +- src/languages/es.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/languages/en.ts b/src/languages/en.ts index 430175e6c170..ec1ee3f56a8c 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -994,7 +994,7 @@ const translations = { cta: 'Review', }, validateAccount: { - title: 'Validate your account to continue using Expensify', + title: 'Validate your account', subtitle: 'Account', cta: 'Validate', }, diff --git a/src/languages/es.ts b/src/languages/es.ts index ef5cd5023b18..98028e852668 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -906,7 +906,7 @@ const translations: TranslationDeepObject = { cta: 'Revisar', }, validateAccount: { - title: 'Valida tu cuenta para continuar usando Expensify', + title: 'Valida tu cuenta', subtitle: 'Cuenta', cta: 'Validar', }, From 1a7e29734fb5cdb5793d3616d985e9ac3fc26a3f Mon Sep 17 00:00:00 2001 From: "James Dean (via MelvinBot)" Date: Thu, 28 May 2026 01:23:29 +0000 Subject: [PATCH 034/435] Apply Polyglot Parrot translations for validateAccount title Remove 'to continue using Expensify' suffix from validateAccount title across all remaining language files (nl, pt-BR, ja, de, fr, zh-hans, pl, it) to match the simplified en.ts and es.ts copy. Co-authored-by: James Dean --- src/languages/de.ts | 2 +- src/languages/fr.ts | 2 +- src/languages/it.ts | 2 +- src/languages/ja.ts | 2 +- src/languages/nl.ts | 2 +- src/languages/pl.ts | 2 +- src/languages/pt-BR.ts | 2 +- src/languages/zh-hans.ts | 2 +- 8 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/languages/de.ts b/src/languages/de.ts index 6a38095edd8d..346fa4b64ebd 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -951,7 +951,7 @@ const translations: TranslationDeepObject = { title: ({cardName}: {cardName?: string}) => (cardName ? `Verbindung der persönlichen Karte ${cardName} reparieren` : 'Verbindung der persönlichen Karte reparieren'), subtitle: 'Wallet', }, - validateAccount: {title: 'Bestätigen Sie Ihr Konto, um Expensify weiter zu verwenden', subtitle: 'Konto', cta: 'Bestätigen'}, + validateAccount: {title: 'Bestätigen Sie Ihr Konto', subtitle: 'Konto', cta: 'Bestätigen'}, fixFailedBilling: {title: 'Wir konnten Ihre hinterlegte Karte nicht belasten', subtitle: 'Abonnement'}, unlockBankAccount: { workspaceTitle: 'Ihr Geschäftskonto wurde gesperrt', diff --git a/src/languages/fr.ts b/src/languages/fr.ts index 8b1470f46f90..030cfb7413f1 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -954,7 +954,7 @@ const translations: TranslationDeepObject = { title: ({cardName}: {cardName?: string}) => (cardName ? `Réparer la connexion de la carte personnelle ${cardName}` : 'Corriger la connexion de la carte personnelle'), subtitle: 'Portefeuille', }, - validateAccount: {title: 'Validez votre compte pour continuer à utiliser Expensify', subtitle: 'Compte', cta: 'Valider'}, + validateAccount: {title: 'Validez votre compte', subtitle: 'Compte', cta: 'Valider'}, fixFailedBilling: {title: 'Nous n’avons pas pu débiter votre carte enregistrée', subtitle: 'Abonnement'}, unlockBankAccount: { workspaceTitle: 'Votre compte bancaire professionnel a été verrouillé', diff --git a/src/languages/it.ts b/src/languages/it.ts index 05e7bda1109b..db4a1ec4ae3a 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -952,7 +952,7 @@ const translations: TranslationDeepObject = { title: ({cardName}: {cardName?: string}) => (cardName ? `Correggi la connessione della carta personale ${cardName}` : 'Correggi connessione carta personale'), subtitle: 'Portafoglio', }, - validateAccount: {title: 'Conferma il tuo account per continuare a usare Expensify', subtitle: 'Account', cta: 'Conferma'}, + validateAccount: {title: 'Conferma il tuo account', subtitle: 'Account', cta: 'Conferma'}, fixFailedBilling: {title: 'Non abbiamo potuto addebitare la carta salvata nel profilo', subtitle: 'Abbonamento'}, unlockBankAccount: { workspaceTitle: 'Il conto bancario della tua azienda è stato bloccato', diff --git a/src/languages/ja.ts b/src/languages/ja.ts index 52f924cbee80..e5e2ed24f429 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -935,7 +935,7 @@ const translations: TranslationDeepObject = { subtitle: ({policyName}: {policyName: string}) => `${policyName} > 会計`, }, fixPersonalCardConnection: {title: ({cardName}: {cardName?: string}) => (cardName ? `${cardName}個人カードの接続を修正` : '個人カードの連携を修正'), subtitle: 'ウォレット'}, - validateAccount: {title: 'Expensify を引き続きご利用いただくには、アカウントを認証してください', subtitle: 'アカウント', cta: '検証する'}, + validateAccount: {title: 'アカウントを認証してください', subtitle: 'アカウント', cta: '検証する'}, fixFailedBilling: {title: '登録されているカードから請求できませんでした', subtitle: 'サブスクリプション'}, unlockBankAccount: { workspaceTitle: 'ビジネス用銀行口座がロックされました', diff --git a/src/languages/nl.ts b/src/languages/nl.ts index 0882a062989d..a0a76d3a9b4b 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -950,7 +950,7 @@ const translations: TranslationDeepObject = { title: ({cardName}: {cardName?: string}) => (cardName ? `Verbinding van persoonlijke kaart ${cardName} herstellen` : 'Verbinding persoonlijke kaart herstellen'), subtitle: 'Portemonnee', }, - validateAccount: {title: 'Valideer je account om Expensify te blijven gebruiken', subtitle: 'Account', cta: 'Valideren'}, + validateAccount: {title: 'Valideer je account', subtitle: 'Account', cta: 'Valideren'}, fixFailedBilling: {title: 'We konden je kaart in ons bestand niet belasten', subtitle: 'Abonnement'}, unlockBankAccount: { workspaceTitle: 'Je zakelijke bankrekening is geblokkeerd', diff --git a/src/languages/pl.ts b/src/languages/pl.ts index 584f54a50230..a3807f7f905f 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -952,7 +952,7 @@ const translations: TranslationDeepObject = { title: ({cardName}: {cardName?: string}) => (cardName ? `Napraw połączenie z prywatną kartą ${cardName}` : 'Napraw połączenie karty prywatnej'), subtitle: 'Portfel', }, - validateAccount: {title: 'Zweryfikuj swoje konto, aby dalej korzystać z Expensify', subtitle: 'Konto', cta: 'Zatwierdź'}, + validateAccount: {title: 'Zweryfikuj swoje konto', subtitle: 'Konto', cta: 'Zatwierdź'}, fixFailedBilling: {title: 'Nie mogliśmy obciążyć zapisanej karty', subtitle: 'Subskrypcja'}, unlockBankAccount: { workspaceTitle: 'Twoje firmowe konto bankowe zostało zablokowane', diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index f97f6980e7df..5c0c7492029a 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -950,7 +950,7 @@ const translations: TranslationDeepObject = { title: ({cardName}: {cardName?: string}) => (cardName ? `Corrigir conexão do cartão pessoal ${cardName}` : 'Corrigir conexão do cartão pessoal'), subtitle: 'Carteira', }, - validateAccount: {title: 'Valide sua conta para continuar usando o Expensify', subtitle: 'Conta', cta: 'Validar'}, + validateAccount: {title: 'Valide sua conta', subtitle: 'Conta', cta: 'Validar'}, fixFailedBilling: {title: 'Não foi possível cobrar o cartão cadastrado', subtitle: 'Assinatura'}, unlockBankAccount: { workspaceTitle: 'Sua conta bancária comercial foi bloqueada', diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index 585e8461e2a2..8031299b0ab7 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -918,7 +918,7 @@ const translations: TranslationDeepObject = { defaultSubtitle: '工作区', subtitle: ({policyName}: {policyName: string}) => `${policyName} > 会计`, }, - validateAccount: {title: '验证您的账户以继续使用 Expensify', subtitle: '账户', cta: '验证'}, + validateAccount: {title: '验证您的账户', subtitle: '账户', cta: '验证'}, fixFailedBilling: {title: '我们无法向您档案中的银行卡收费', subtitle: '订阅'}, unlockBankAccount: { workspaceTitle: '您的企业银行账户已被锁定', From d228bd14cfe02775591c05f06d1904d74cf01102 Mon Sep 17 00:00:00 2001 From: "Carlos Alvarez (via MelvinBot)" Date: Thu, 28 May 2026 02:25:21 +0000 Subject: [PATCH 035/435] Increase recent attendees NVP max size from 5 to 40 The recentAttendees NVP was capped at MAX_RECENT_REPORTS_TO_SHOW (5), which is a UI display constant. This adds a separate MAX_RECENT_ATTENDEES constant (40) for the NVP storage cap, increasing the pool of attendees searchable in the New Expensify attendees dropdown. Co-authored-by: Carlos Alvarez --- src/CONST/index.ts | 1 + src/libs/actions/IOU/UpdateMoneyRequest.ts | 2 +- tests/actions/IOUTest/UpdateMoneyRequestTest.ts | 4 ++-- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/src/CONST/index.ts b/src/CONST/index.ts index db9567f28314..1556bf9ec773 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -3596,6 +3596,7 @@ const CONST = { IOU: { MAX_RECENT_REPORTS_TO_SHOW: 5, + MAX_RECENT_ATTENDEES: 40, // This will guranatee that the quantity input will not exceed 9,007,199,254,740,991 (Number.MAX_SAFE_INTEGER). QUANTITY_MAX_LENGTH: 12, // This is the transactionID used when going through the create expense flow so that it mimics a real transaction (like the edit flow) diff --git a/src/libs/actions/IOU/UpdateMoneyRequest.ts b/src/libs/actions/IOU/UpdateMoneyRequest.ts index 5f9b15b60312..3eb26503dc08 100644 --- a/src/libs/actions/IOU/UpdateMoneyRequest.ts +++ b/src/libs/actions/IOU/UpdateMoneyRequest.ts @@ -1551,7 +1551,7 @@ function getUpdateMoneyRequestParams(params: GetUpdateMoneyRequestParamsType): U getRecentAttendees(), // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing (attendee) => attendee.email || attendee.displayName, - ).slice(0, CONST.IOU.MAX_RECENT_REPORTS_TO_SHOW), + ).slice(0, CONST.IOU.MAX_RECENT_ATTENDEES), }); } diff --git a/tests/actions/IOUTest/UpdateMoneyRequestTest.ts b/tests/actions/IOUTest/UpdateMoneyRequestTest.ts index c63112d19646..b1f732cbbe95 100644 --- a/tests/actions/IOUTest/UpdateMoneyRequestTest.ts +++ b/tests/actions/IOUTest/UpdateMoneyRequestTest.ts @@ -679,7 +679,7 @@ describe('actions/IOU/UpdateMoneyRequest', () => { }); await waitForBatchedUpdates(); - // Then the recent attendees should be updated with a maximum of 5 attendees + // Then all 6 recent attendees should be stored (below the max of 40) const recentAttendees = await new Promise>((resolve) => { const connection = Onyx.connectWithoutView({ key: ONYXKEYS.NVP_RECENT_ATTENDEES, @@ -689,7 +689,7 @@ describe('actions/IOU/UpdateMoneyRequest', () => { }, }); }); - expect(recentAttendees?.length).toBe(CONST.IOU.MAX_RECENT_REPORTS_TO_SHOW); + expect(recentAttendees?.length).toBe(6); }); it('should keep displayName-only attendees in recent attendees', async () => { From 00199797ed225335be787e623fcd7e25d528c0c8 Mon Sep 17 00:00:00 2001 From: "Carlos Alvarez (via MelvinBot)" Date: Thu, 28 May 2026 02:38:58 +0000 Subject: [PATCH 036/435] Add blank line after MAX_RECENT_ATTENDEES constant Co-authored-by: Carlos Alvarez --- src/CONST/index.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 1556bf9ec773..bb06f3e49ab7 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -3597,6 +3597,7 @@ const CONST = { IOU: { MAX_RECENT_REPORTS_TO_SHOW: 5, MAX_RECENT_ATTENDEES: 40, + // This will guranatee that the quantity input will not exceed 9,007,199,254,740,991 (Number.MAX_SAFE_INTEGER). QUANTITY_MAX_LENGTH: 12, // This is the transactionID used when going through the create expense flow so that it mimics a real transaction (like the edit flow) From 0700258610b32ea45dfb4c1920b758420e0478d6 Mon Sep 17 00:00:00 2001 From: Adam Grzybowski Date: Thu, 28 May 2026 17:01:05 +0200 Subject: [PATCH 037/435] Add review expenses row to ForYouSection --- src/ONYXKEYS.ts | 2 + src/languages/en.ts | 1 + .../OnyxDerived/ONYX_DERIVED_VALUES.ts | 2 + .../OnyxDerived/configs/flaggedExpenses.ts | 87 ++++++ src/pages/home/ForYouSection/index.tsx | 62 +++- src/selectors/Todos.ts | 47 +++- src/types/onyx/DerivedValues.ts | 18 ++ src/types/onyx/index.ts | 2 + tests/ui/ForYouSectionTest.tsx | 193 ++++++++++++- tests/unit/OnyxDerived/flaggedExpensesTest.ts | 264 ++++++++++++++++++ tests/unit/selectors/TodosTest.ts | 70 ++++- 11 files changed, 733 insertions(+), 15 deletions(-) create mode 100644 src/libs/actions/OnyxDerived/configs/flaggedExpenses.ts create mode 100644 tests/unit/OnyxDerived/flaggedExpensesTest.ts diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts index 8e30de4e3f7b..9ac7bb02a226 100755 --- a/src/ONYXKEYS.ts +++ b/src/ONYXKEYS.ts @@ -1159,6 +1159,7 @@ const ONYXKEYS = { TODOS: 'todos', RAM_ONLY_SORTED_REPORT_ACTIONS: 'sortedReportActions', OPEN_AND_SUBMITTED_REPORTS_BY_POLICY_ID: 'openAndSubmittedReportsByPolicyID', + FLAGGED_EXPENSES: 'flaggedExpenses', }, /** Stores HybridApp specific state required to interoperate with OldDot */ @@ -1637,6 +1638,7 @@ type OnyxDerivedValuesMapping = { [ONYXKEYS.DERIVED.TODOS]: OnyxTypes.TodosDerivedValue; [ONYXKEYS.DERIVED.RAM_ONLY_SORTED_REPORT_ACTIONS]: OnyxTypes.SortedReportActionsDerivedValue; [ONYXKEYS.DERIVED.OPEN_AND_SUBMITTED_REPORTS_BY_POLICY_ID]: OnyxTypes.OpenAndSubmittedReportsByPolicyIDDerivedValue; + [ONYXKEYS.DERIVED.FLAGGED_EXPENSES]: OnyxTypes.FlaggedExpensesDerivedValue; }; type OnyxValues = OnyxValuesMapping & OnyxCollectionValuesMapping & OnyxFormValuesMapping & OnyxFormDraftValuesMapping & OnyxDerivedValuesMapping; diff --git a/src/languages/en.ts b/src/languages/en.ts index 88e0bbe8b673..f30a79060920 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -1037,6 +1037,7 @@ const translations = { menuItemDescription: 'See what Expensify can do in 2 min', }, forYouSection: { + reviewExpenses: ({count}: {count: number}) => `Review ${count} ${count === 1 ? 'expense' : 'expenses'}`, submit: ({count}: {count: number}) => `Submit ${count} ${count === 1 ? 'report' : 'reports'}`, approve: ({count}: {count: number}) => `Approve ${count} ${count === 1 ? 'report' : 'reports'}`, pay: ({count}: {count: number}) => `Pay ${count} ${count === 1 ? 'report' : 'reports'}`, diff --git a/src/libs/actions/OnyxDerived/ONYX_DERIVED_VALUES.ts b/src/libs/actions/OnyxDerived/ONYX_DERIVED_VALUES.ts index 161c7f72ecf8..63d8bfd1478c 100644 --- a/src/libs/actions/OnyxDerived/ONYX_DERIVED_VALUES.ts +++ b/src/libs/actions/OnyxDerived/ONYX_DERIVED_VALUES.ts @@ -1,6 +1,7 @@ import type {ValueOf} from 'type-fest'; import ONYXKEYS from '@src/ONYXKEYS'; import cardFeedErrorsConfig from './configs/cardFeedErrors'; +import flaggedExpensesConfig from './configs/flaggedExpenses'; import nonPersonalAndWorkspaceCardListConfig from './configs/nonPersonalAndWorkspaceCardList'; import openAndSubmittedReportsByPolicyIDConfig from './configs/openAndSubmittedReportsByPolicyID'; import outstandingReportsByPolicyIDConfig from './configs/outstandingReportsByPolicyID'; @@ -27,6 +28,7 @@ const ONYX_DERIVED_VALUES = { [ONYXKEYS.DERIVED.TODOS]: todosConfig, [ONYXKEYS.DERIVED.RAM_ONLY_SORTED_REPORT_ACTIONS]: sortedReportActionsConfig, [ONYXKEYS.DERIVED.OPEN_AND_SUBMITTED_REPORTS_BY_POLICY_ID]: openAndSubmittedReportsByPolicyIDConfig, + [ONYXKEYS.DERIVED.FLAGGED_EXPENSES]: flaggedExpensesConfig, } as const satisfies { // eslint-disable-next-line @typescript-eslint/no-explicit-any [Key in ValueOf]: OnyxDerivedValueConfig; diff --git a/src/libs/actions/OnyxDerived/configs/flaggedExpenses.ts b/src/libs/actions/OnyxDerived/configs/flaggedExpenses.ts new file mode 100644 index 000000000000..e555f50f3107 --- /dev/null +++ b/src/libs/actions/OnyxDerived/configs/flaggedExpenses.ts @@ -0,0 +1,87 @@ +import createOnyxDerivedValueConfig from '@userActions/OnyxDerived/createOnyxDerivedValueConfig'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {FlaggedExpensesDerivedValue} from '@src/types/onyx'; +import type Report from '@src/types/onyx/Report'; +import type TransactionViolations from '@src/types/onyx/TransactionViolation'; + +type FlaggedExpenseEntry = FlaggedExpensesDerivedValue['flaggedExpenses'][number]; + +const EMPTY_VALUE: FlaggedExpensesDerivedValue = {flaggedExpenses: []}; + +/** + * Returns true when this report is an OPEN/OPEN expense report owned by the current user. + * + * `currentUserAccountID` is required. Callers should pass `session?.accountID ?? CONST.DEFAULT_NUMBER_ID` + * so that the ownership check fails closed when the session is not yet populated (no real ownerAccountID is 0). + */ +function isCurrentUserOpenExpenseReport(report: Report | null | undefined, currentUserAccountID: number): boolean { + if (!report) { + return false; + } + if (report.type !== CONST.REPORT.TYPE.EXPENSE) { + return false; + } + if (report.ownerAccountID !== currentUserAccountID) { + return false; + } + return report.stateNum === CONST.REPORT.STATE_NUM.OPEN && report.statusNum === CONST.REPORT.STATUS_NUM.OPEN; +} + +/** + * Returns true when the given violation list has at least one entry that should surface in the + * `Review X expenses` row: a transaction-level violation that the user can act on. Violations + * marked `showInReview === false` and report-field violations (`fieldRequired`) are excluded. + */ +function hasReviewableViolation(violations: TransactionViolations | null | undefined): boolean { + if (!violations || violations.length === 0) { + return false; + } + + return violations.some((violation) => { + if (!violation) { + return false; + } + if (violation.showInReview === false) { + return false; + } + if (violation.name === CONST.REPORT_VIOLATIONS.FIELD_REQUIRED) { + return false; + } + return true; + }); +} + +export default createOnyxDerivedValueConfig({ + key: ONYXKEYS.DERIVED.FLAGGED_EXPENSES, + dependencies: [ONYXKEYS.COLLECTION.REPORT, ONYXKEYS.COLLECTION.TRANSACTION, ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS, ONYXKEYS.SESSION], + compute: ([allReports, allTransactions, allTransactionViolations, session]) => { + if (!allReports || !allTransactions) { + return EMPTY_VALUE; + } + + const currentUserAccountID = session?.accountID ?? CONST.DEFAULT_NUMBER_ID; + const flaggedExpenses: FlaggedExpenseEntry[] = []; + + for (const transactionKey of Object.keys(allTransactions)) { + const transaction = allTransactions[transactionKey]; + if (!transaction?.transactionID || !transaction.reportID) { + continue; + } + + const report = allReports[`${ONYXKEYS.COLLECTION.REPORT}${transaction.reportID}`]; + if (!isCurrentUserOpenExpenseReport(report, currentUserAccountID)) { + continue; + } + + const violations = allTransactionViolations?.[`${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${transaction.transactionID}`]; + if (!hasReviewableViolation(violations)) { + continue; + } + + flaggedExpenses.push({transactionID: transaction.transactionID, reportID: transaction.reportID}); + } + + return {flaggedExpenses}; + }, +}); diff --git a/src/pages/home/ForYouSection/index.tsx b/src/pages/home/ForYouSection/index.tsx index ac4074b2224c..f4f7d537ffd7 100644 --- a/src/pages/home/ForYouSection/index.tsx +++ b/src/pages/home/ForYouSection/index.tsx @@ -15,7 +15,7 @@ import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import {accountIDSelector} from '@src/selectors/Session'; -import todosReportCountsSelector, {EMPTY_TODOS_SINGLE_REPORT_IDS, todosSingleReportIDsSelector} from '@src/selectors/Todos'; +import todosReportCountsSelector, {EMPTY_FLAGGED_EXPENSES_REVIEW, EMPTY_TODOS_SINGLE_REPORT_IDS, flaggedExpensesReviewSelector, todosSingleReportIDsSelector} from '@src/selectors/Todos'; import EmptyState from './EmptyState'; import ForYouSkeleton from './ForYouSkeleton'; @@ -29,24 +29,33 @@ function ForYouSection() { const [isLoadingReportData = false] = useOnyx(ONYXKEYS.IS_LOADING_REPORT_DATA); const [reportCounts = CONST.EMPTY_TODOS_REPORT_COUNTS] = useOnyx(ONYXKEYS.DERIVED.TODOS, {selector: todosReportCountsSelector}); const [singleReportIDs = EMPTY_TODOS_SINGLE_REPORT_IDS] = useOnyx(ONYXKEYS.DERIVED.TODOS, {selector: todosSingleReportIDsSelector}); + const [flaggedExpensesReview = EMPTY_FLAGGED_EXPENSES_REVIEW] = useOnyx(ONYXKEYS.DERIVED.FLAGGED_EXPENSES, {selector: flaggedExpensesReviewSelector}); - const icons = useMemoizedLazyExpensifyIcons(['MoneyBag', 'Send', 'ThumbsUp', 'Export']); + const icons = useMemoizedLazyExpensifyIcons(['Exclamation', 'MoneyBag', 'Send', 'ThumbsUp', 'Export']); const submitCount = reportCounts?.[CONST.SEARCH.SEARCH_KEYS.SUBMIT] ?? 0; const approveCount = reportCounts?.[CONST.SEARCH.SEARCH_KEYS.APPROVE] ?? 0; const payCount = reportCounts?.[CONST.SEARCH.SEARCH_KEYS.PAY] ?? 0; const exportCount = reportCounts?.[CONST.SEARCH.SEARCH_KEYS.EXPORT] ?? 0; + const flaggedExpensesCount = flaggedExpensesReview.count; - const hasAnyTodos = submitCount > 0 || approveCount > 0 || payCount > 0 || exportCount > 0; + const hasAnyTodos = flaggedExpensesCount > 0 || submitCount > 0 || approveCount > 0 || payCount > 0 || exportCount > 0; + + const navigateToReport = useCallback( + (reportID: string) => { + if (shouldUseNarrowLayout) { + Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(reportID, undefined, undefined, ROUTES.HOME)); + return; + } + Navigation.navigate(ROUTES.EXPENSE_REPORT_RHP.getRoute({reportID, backTo: ROUTES.HOME})); + }, + [shouldUseNarrowLayout], + ); const createNavigationHandler = useCallback( (action: string, queryParams: Record, reportID?: string) => () => { if (reportID) { - if (shouldUseNarrowLayout) { - Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(reportID, undefined, undefined, ROUTES.HOME)); - } else { - Navigation.navigate(ROUTES.EXPENSE_REPORT_RHP.getRoute({reportID, backTo: ROUTES.HOME})); - } + navigateToReport(reportID); return; } @@ -60,12 +69,29 @@ function ForYouSection() { }), ); }, - [shouldUseNarrowLayout], + [navigateToReport], + ); + + const createReviewExpensesHandler = useCallback( + (firstReportID: string | undefined) => () => { + if (!firstReportID) { + return; + } + navigateToReport(firstReportID); + }, + [navigateToReport], ); const todoItems = useMemo( () => [ + { + key: 'reviewExpenses', + count: flaggedExpensesCount, + icon: icons.Exclamation, + translationKey: 'homePage.forYouSection.reviewExpenses' as const, + handler: createReviewExpensesHandler(flaggedExpensesReview.firstReportID), + }, { key: 'submit', count: submitCount, @@ -103,7 +129,23 @@ function ForYouSection() { ), }, ].filter((item) => item.count > 0), - [accountID, approveCount, createNavigationHandler, exportCount, icons.Export, icons.MoneyBag, icons.Send, icons.ThumbsUp, payCount, singleReportIDs, submitCount], + [ + accountID, + approveCount, + createNavigationHandler, + createReviewExpensesHandler, + exportCount, + flaggedExpensesCount, + flaggedExpensesReview.firstReportID, + icons.Exclamation, + icons.Export, + icons.MoneyBag, + icons.Send, + icons.ThumbsUp, + payCount, + singleReportIDs, + submitCount, + ], ); const renderTodoItems = () => ( diff --git a/src/selectors/Todos.ts b/src/selectors/Todos.ts index a1108233c703..84ff216de5c0 100644 --- a/src/selectors/Todos.ts +++ b/src/selectors/Todos.ts @@ -1,7 +1,7 @@ import {shallowEqual} from 'fast-equals'; import type {OnyxEntry} from 'react-native-onyx'; import CONST from '@src/CONST'; -import type {TodosDerivedValue} from '@src/types/onyx'; +import type {FlaggedExpensesDerivedValue, TodosDerivedValue} from '@src/types/onyx'; const EMPTY_TODOS_SINGLE_REPORT_IDS = Object.freeze({ [CONST.SEARCH.SEARCH_KEYS.SUBMIT]: undefined, @@ -66,5 +66,48 @@ const todosSingleReportIDsSelector = (todos: OnyxEntry) => { return newValue; }; +type FlaggedExpensesReview = { + /** Total number of flagged expenses */ + count: number; + /** Transaction ID of the first flagged expense, used to seed the carousel handoff */ + firstTransactionID: string | undefined; + /** Parent report ID of the first flagged expense */ + firstReportID: string | undefined; +}; + +const EMPTY_FLAGGED_EXPENSES_REVIEW: FlaggedExpensesReview = Object.freeze({ + count: 0, + firstTransactionID: undefined, + firstReportID: undefined, +}) as FlaggedExpensesReview; + +// Manual memoization mirrors `todosSingleReportIDsSelector`: ForYouSection's useMemo dependency +// list consumes the returned object as a whole, so we need referential stability across +// equivalent derived snapshots to avoid cascading re-renders on every Onyx churn. +let previousFlaggedExpensesReview: FlaggedExpensesReview = EMPTY_FLAGGED_EXPENSES_REVIEW; + +const flaggedExpensesReviewSelector = (flaggedExpensesValue: OnyxEntry): FlaggedExpensesReview => { + const flaggedExpenses = flaggedExpensesValue?.flaggedExpenses; + if (!flaggedExpenses || flaggedExpenses.length === 0) { + previousFlaggedExpensesReview = EMPTY_FLAGGED_EXPENSES_REVIEW; + return EMPTY_FLAGGED_EXPENSES_REVIEW; + } + + const first = flaggedExpenses.at(0); + const newValue: FlaggedExpensesReview = { + count: flaggedExpenses.length, + firstTransactionID: first?.transactionID, + firstReportID: first?.reportID, + }; + + if (shallowEqual(previousFlaggedExpensesReview, newValue)) { + return previousFlaggedExpensesReview; + } + + previousFlaggedExpensesReview = newValue; + return newValue; +}; + export default todosReportCountsSelector; -export {todosSingleReportIDsSelector, EMPTY_TODOS_SINGLE_REPORT_IDS}; +export {EMPTY_FLAGGED_EXPENSES_REVIEW, EMPTY_TODOS_SINGLE_REPORT_IDS, flaggedExpensesReviewSelector, todosSingleReportIDsSelector}; +export type {FlaggedExpensesReview}; diff --git a/src/types/onyx/DerivedValues.ts b/src/types/onyx/DerivedValues.ts index bbcecb4f4d6f..46128996dce1 100644 --- a/src/types/onyx/DerivedValues.ts +++ b/src/types/onyx/DerivedValues.ts @@ -266,6 +266,23 @@ type TodosDerivedValue = { transactionsByReportID: Record; }; +/** + * The derived value for flagged expenses. + * + * Aggregates transactions on the current user's `OPEN`/`OPEN` expense reports that have + * at least one transaction-level violation (excluding `showInReview === false` entries and + * `REPORT_VIOLATIONS.FIELD_REQUIRED` entries that may slip into the collection). + */ +type FlaggedExpensesDerivedValue = { + /** Ordered list of flagged transactions with their parent report IDs */ + flaggedExpenses: Array<{ + /** ID of the flagged transaction */ + transactionID: string; + /** ID of the parent expense report */ + reportID: string; + }>; +}; + /** * The derived value for sorted report actions, last report actions, and cached transaction thread report IDs. */ @@ -297,6 +314,7 @@ export type { CardFeedErrorsDerivedValue, TodosDerivedValue, TodoMetadata, + FlaggedExpensesDerivedValue, CardFeedErrorsObject, CardFeedErrorState, CardFeedErrors, diff --git a/src/types/onyx/index.ts b/src/types/onyx/index.ts index 574568fa4d17..4346973a81e2 100644 --- a/src/types/onyx/index.ts +++ b/src/types/onyx/index.ts @@ -46,6 +46,7 @@ import type {CurrencyList} from './Currency'; import type CustomStatusDraft from './CustomStatusDraft'; import type { CardFeedErrorsDerivedValue, + FlaggedExpensesDerivedValue, NonPersonalAndWorkspaceCardListDerivedValue, OpenAndSubmittedReportsByPolicyIDDerivedValue, OutstandingReportsByPolicyIDDerivedValue, @@ -395,6 +396,7 @@ export type { CardFeedErrorsDerivedValue, TodosDerivedValue, TodoMetadata, + FlaggedExpensesDerivedValue, ScheduleCallDraft, ValidateUserAndGetAccessiblePolicies, VacationDelegate, diff --git a/tests/ui/ForYouSectionTest.tsx b/tests/ui/ForYouSectionTest.tsx index ebdcf1ce65ca..02792c2de4c5 100644 --- a/tests/ui/ForYouSectionTest.tsx +++ b/tests/ui/ForYouSectionTest.tsx @@ -6,7 +6,7 @@ import Navigation from '@libs/Navigation/Navigation'; import ForYouSection from '@pages/home/ForYouSection'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; -import type {TodosDerivedValue} from '@src/types/onyx'; +import type {FlaggedExpensesDerivedValue, TodosDerivedValue} from '@src/types/onyx'; import waitForBatchedUpdatesWithAct from '../utils/waitForBatchedUpdatesWithAct'; jest.mock('@libs/Navigation/Navigation', () => ({ @@ -44,12 +44,15 @@ jest.mock('@hooks/useThemeStyles', () => jest.mock('@hooks/useTheme', () => jest.fn(() => ({}))); +const EXCLAMATION_ASSET = {testID: 'exclamation-icon'}; + jest.mock('@hooks/useLazyAsset', () => ({ useMemoizedLazyExpensifyIcons: jest.fn(() => ({ MoneyBag: null, Send: null, ThumbsUp: null, Export: null, + Exclamation: EXCLAMATION_ASSET, })), useMemoizedLazyIllustrations: jest.fn(() => ({ ThumbsUpStars: null, @@ -75,6 +78,8 @@ const BASE_TODOS: TodosDerivedValue = { transactionsByReportID: {}, }; +const EMPTY_FLAGGED_EXPENSES: FlaggedExpensesDerivedValue = {flaggedExpenses: []}; + function renderForYouSection() { return render(); } @@ -125,6 +130,7 @@ describe('ForYouSection', () => { it('renders EmptyState when there are no todos', async () => { await act(async () => { await Onyx.set(ONYXKEYS.DERIVED.TODOS, BASE_TODOS); + await Onyx.set(ONYXKEYS.DERIVED.FLAGGED_EXPENSES, EMPTY_FLAGGED_EXPENSES); }); await waitForBatchedUpdatesWithAct(); @@ -135,6 +141,191 @@ describe('ForYouSection', () => { }); }); + describe('review row', () => { + it('is not rendered when there are no flagged expenses', async () => { + await act(async () => { + await Onyx.set(ONYXKEYS.DERIVED.TODOS, { + ...BASE_TODOS, + reportsToSubmit: [{reportID: '1'} as TodosDerivedValue['reportsToSubmit'][number]], + }); + await Onyx.set(ONYXKEYS.DERIVED.FLAGGED_EXPENSES, EMPTY_FLAGGED_EXPENSES); + }); + await waitForBatchedUpdatesWithAct(); + + renderForYouSection(); + await waitForBatchedUpdatesWithAct(); + + expect(screen.queryByText(/homePage\.forYouSection\.reviewExpenses/)).not.toBeOnTheScreen(); + }); + + it('renders with the count-1 string when exactly one expense is flagged', async () => { + await act(async () => { + await Onyx.set(ONYXKEYS.DERIVED.TODOS, BASE_TODOS); + await Onyx.set(ONYXKEYS.DERIVED.FLAGGED_EXPENSES, { + flaggedExpenses: [{transactionID: 't1', reportID: 'r1'}], + }); + }); + await waitForBatchedUpdatesWithAct(); + + renderForYouSection(); + await waitForBatchedUpdatesWithAct(); + + expect(screen.getByText('homePage.forYouSection.reviewExpenses:{"count":1}')).toBeOnTheScreen(); + }); + + it('renders with the count-N string when multiple expenses are flagged', async () => { + await act(async () => { + await Onyx.set(ONYXKEYS.DERIVED.TODOS, BASE_TODOS); + await Onyx.set(ONYXKEYS.DERIVED.FLAGGED_EXPENSES, { + flaggedExpenses: [ + {transactionID: 't1', reportID: 'r1'}, + {transactionID: 't2', reportID: 'r2'}, + {transactionID: 't3', reportID: 'r3'}, + ], + }); + }); + await waitForBatchedUpdatesWithAct(); + + renderForYouSection(); + await waitForBatchedUpdatesWithAct(); + + expect(screen.getByText('homePage.forYouSection.reviewExpenses:{"count":3}')).toBeOnTheScreen(); + }); + + it('renders the review row above submit/approve/pay/export rows', async () => { + await act(async () => { + await Onyx.set(ONYXKEYS.DERIVED.TODOS, { + ...BASE_TODOS, + reportsToSubmit: [{reportID: 's1'} as TodosDerivedValue['reportsToSubmit'][number]], + reportsToApprove: [{reportID: 'a1'} as TodosDerivedValue['reportsToApprove'][number]], + reportsToPay: [{reportID: 'p1'} as TodosDerivedValue['reportsToPay'][number]], + reportsToExport: [{reportID: 'e1'} as TodosDerivedValue['reportsToExport'][number]], + }); + await Onyx.set(ONYXKEYS.DERIVED.FLAGGED_EXPENSES, { + flaggedExpenses: [{transactionID: 't1', reportID: 'r1'}], + }); + }); + await waitForBatchedUpdatesWithAct(); + + renderForYouSection(); + await waitForBatchedUpdatesWithAct(); + + const reviewTitle = screen.getByText('homePage.forYouSection.reviewExpenses:{"count":1}'); + const submitTitle = screen.getByText('homePage.forYouSection.submit:{"count":1}'); + const approveTitle = screen.getByText('homePage.forYouSection.approve:{"count":1}'); + const payTitle = screen.getByText('homePage.forYouSection.pay:{"count":1}'); + const exportTitle = screen.getByText('homePage.forYouSection.export:{"count":1}'); + + // jest-native renderer assigns sequential _nativeTag-like positions; we rely on + // the order returned by getAllByText to verify rendering order. + const allTitles = screen.getAllByText(/homePage\.forYouSection\.(reviewExpenses|submit|approve|pay|export):/); + const getChildren = (node: {props: {children?: unknown}}) => node.props.children; + const titleOrder = allTitles.map(getChildren); + + expect(titleOrder.at(0)).toBe(getChildren(reviewTitle)); + expect(titleOrder).toEqual([getChildren(reviewTitle), getChildren(submitTitle), getChildren(approveTitle), getChildren(payTitle), getChildren(exportTitle)]); + }); + + it('exposes a Begin CTA and uses the Exclamation icon asset', async () => { + await act(async () => { + await Onyx.set(ONYXKEYS.DERIVED.TODOS, BASE_TODOS); + await Onyx.set(ONYXKEYS.DERIVED.FLAGGED_EXPENSES, { + flaggedExpenses: [{transactionID: 't1', reportID: 'r1'}], + }); + }); + await waitForBatchedUpdatesWithAct(); + + const {UNSAFE_root: unsafeRoot} = renderForYouSection(); + await waitForBatchedUpdatesWithAct(); + + expect(screen.getByText('Begin')).toBeOnTheScreen(); + + // The Exclamation asset should be passed as the `icon` prop on at least one BaseWidgetItem. + // We look for any rendered element whose `icon` prop is the EXCLAMATION_ASSET reference. + const matchingNodes = unsafeRoot.findAll((node) => node.props && (node.props as {icon?: unknown}).icon === EXCLAMATION_ASSET); + expect(matchingNodes.length).toBeGreaterThan(0); + }); + }); + + describe('review row navigation', () => { + describe('wide layout', () => { + it('navigates to EXPENSE_REPORT_RHP with the first flagged expense parent report and backTo=HOME', async () => { + await act(async () => { + await Onyx.set(ONYXKEYS.DERIVED.TODOS, BASE_TODOS); + await Onyx.set(ONYXKEYS.DERIVED.FLAGGED_EXPENSES, { + flaggedExpenses: [ + {transactionID: 't1', reportID: 'r1'}, + {transactionID: 't2', reportID: 'r2'}, + ], + }); + }); + await waitForBatchedUpdatesWithAct(); + + renderForYouSection(); + await waitForBatchedUpdatesWithAct(); + + pressFirstBeginButton(); + + expect(mockNavigate).toHaveBeenCalledTimes(1); + expect(mockNavigate).toHaveBeenCalledWith(ROUTES.EXPENSE_REPORT_RHP.getRoute({reportID: 'r1', backTo: ROUTES.HOME})); + }); + + it('still uses the per-report RHP route when only one expense is flagged', async () => { + await act(async () => { + await Onyx.set(ONYXKEYS.DERIVED.TODOS, BASE_TODOS); + await Onyx.set(ONYXKEYS.DERIVED.FLAGGED_EXPENSES, { + flaggedExpenses: [{transactionID: 't1', reportID: 'rOnly'}], + }); + }); + await waitForBatchedUpdatesWithAct(); + + renderForYouSection(); + await waitForBatchedUpdatesWithAct(); + + pressFirstBeginButton(); + + expect(mockNavigate).toHaveBeenCalledTimes(1); + expect(mockNavigate).toHaveBeenCalledWith(ROUTES.EXPENSE_REPORT_RHP.getRoute({reportID: 'rOnly', backTo: ROUTES.HOME})); + }); + }); + + describe('narrow layout', () => { + beforeEach(() => { + mockUseResponsiveLayout.mockReturnValue({ + shouldUseNarrowLayout: true, + isSmallScreenWidth: true, + isInNarrowPaneModal: false, + isExtraSmallScreenHeight: false, + isMediumScreenWidth: false, + isLargeScreenWidth: false, + isExtraLargeScreenWidth: false, + isExtraSmallScreenWidth: false, + isSmallScreen: true, + onboardingIsMediumOrLargerScreenWidth: false, + isInLandscapeMode: false, + }); + }); + + it('navigates to REPORT_WITH_ID with the first flagged expense parent report and backTo=HOME', async () => { + await act(async () => { + await Onyx.set(ONYXKEYS.DERIVED.TODOS, BASE_TODOS); + await Onyx.set(ONYXKEYS.DERIVED.FLAGGED_EXPENSES, { + flaggedExpenses: [{transactionID: 't1', reportID: 'rNarrow'}], + }); + }); + await waitForBatchedUpdatesWithAct(); + + renderForYouSection(); + await waitForBatchedUpdatesWithAct(); + + pressFirstBeginButton(); + + expect(mockNavigate).toHaveBeenCalledTimes(1); + expect(mockNavigate).toHaveBeenCalledWith(ROUTES.REPORT_WITH_ID.getRoute('rNarrow', undefined, undefined, ROUTES.HOME)); + }); + }); + }); + describe('navigation with multiple reports (search route)', () => { it('navigates to SEARCH_ROOT when submit has multiple reports', async () => { await act(async () => { diff --git a/tests/unit/OnyxDerived/flaggedExpensesTest.ts b/tests/unit/OnyxDerived/flaggedExpensesTest.ts new file mode 100644 index 000000000000..4b59525f2d10 --- /dev/null +++ b/tests/unit/OnyxDerived/flaggedExpensesTest.ts @@ -0,0 +1,264 @@ +import type {OnyxCollection} from 'react-native-onyx'; +import flaggedExpensesConfig from '@libs/actions/OnyxDerived/configs/flaggedExpenses'; +import type {DerivedValueContext} from '@libs/actions/OnyxDerived/types'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {Report, Session, Transaction, TransactionViolations} from '@src/types/onyx'; +import {createMockReport} from '../../utils/ReportTestUtils'; + +const CURRENT_USER_ACCOUNT_ID = 1; +const OTHER_USER_ACCOUNT_ID = 2; +const POLICY_ID_1 = 'policy1'; +const POLICY_ID_2 = 'policy2'; + +function createExpenseReport(reportID: string, overrides: Partial = {}): Report { + return createMockReport({ + reportID, + type: CONST.REPORT.TYPE.EXPENSE, + ownerAccountID: CURRENT_USER_ACCOUNT_ID, + policyID: POLICY_ID_1, + stateNum: CONST.REPORT.STATE_NUM.OPEN, + statusNum: CONST.REPORT.STATUS_NUM.OPEN, + ...overrides, + }); +} + +function createTransaction(transactionID: string, reportID: string, overrides: Partial = {}): Transaction { + return { + transactionID, + reportID, + amount: 100, + currency: 'USD', + created: '2024-01-01', + merchant: 'Test Merchant', + ...overrides, + } as Transaction; +} + +function buildReports(...reports: Report[]): OnyxCollection { + const result: OnyxCollection = {}; + for (const report of reports) { + result[`${ONYXKEYS.COLLECTION.REPORT}${report.reportID}`] = report; + } + return result; +} + +function buildTransactions(...transactions: Transaction[]): OnyxCollection { + const result: OnyxCollection = {}; + for (const transaction of transactions) { + result[`${ONYXKEYS.COLLECTION.TRANSACTION}${transaction.transactionID}`] = transaction; + } + return result; +} + +function buildViolations(violationsByTransactionID: Record): OnyxCollection { + const result: OnyxCollection = {}; + for (const [transactionID, violations] of Object.entries(violationsByTransactionID)) { + result[`${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${transactionID}`] = violations; + } + return result; +} + +function createSession(accountID: number = CURRENT_USER_ACCOUNT_ID): Session { + return {accountID} as Session; +} + +const {compute} = flaggedExpensesConfig; +const emptyContext = {} as DerivedValueContext< + typeof ONYXKEYS.DERIVED.FLAGGED_EXPENSES, + [typeof ONYXKEYS.COLLECTION.REPORT, typeof ONYXKEYS.COLLECTION.TRANSACTION, typeof ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS, typeof ONYXKEYS.SESSION] +>; + +describe('flaggedExpenses derived value', () => { + describe('presence and count', () => { + it('reports zero flagged expenses and an empty list when no transactions have violations', () => { + const report = createExpenseReport('r1'); + const transaction = createTransaction('t1', 'r1'); + const result = compute([buildReports(report), buildTransactions(transaction), {}, createSession()], emptyContext); + + expect(result.flaggedExpenses).toEqual([]); + }); + + it('counts two flagged transactions on OPEN/OPEN expense reports across two workspaces', () => { + const report1 = createExpenseReport('r1', {policyID: POLICY_ID_1}); + const report2 = createExpenseReport('r2', {policyID: POLICY_ID_2}); + const transaction1 = createTransaction('t1', 'r1'); + const transaction2 = createTransaction('t2', 'r2'); + const violations = buildViolations({ + t1: [{type: CONST.VIOLATION_TYPES.VIOLATION, name: CONST.VIOLATIONS.MISSING_CATEGORY}], + t2: [{type: CONST.VIOLATION_TYPES.VIOLATION, name: CONST.VIOLATIONS.MISSING_CATEGORY}], + }); + + const result = compute([buildReports(report1, report2), buildTransactions(transaction1, transaction2), violations, createSession()], emptyContext); + + expect(result.flaggedExpenses).toHaveLength(2); + const ids = result.flaggedExpenses.map((entry) => entry.transactionID).sort(); + expect(ids).toEqual(['t1', 't2']); + const reportIDs = result.flaggedExpenses.map((entry) => entry.reportID).sort(); + expect(reportIDs).toEqual(['r1', 'r2']); + }); + + it('counts a single transaction with multiple violations as 1 expense', () => { + const report = createExpenseReport('r1'); + const transaction = createTransaction('t1', 'r1'); + const violations = buildViolations({ + t1: [ + {type: CONST.VIOLATION_TYPES.VIOLATION, name: CONST.VIOLATIONS.MISSING_CATEGORY}, + {type: CONST.VIOLATION_TYPES.VIOLATION, name: CONST.VIOLATIONS.MISSING_TAG}, + ], + }); + + const result = compute([buildReports(report), buildTransactions(transaction), violations, createSession()], emptyContext); + + expect(result.flaggedExpenses).toHaveLength(1); + expect(result.flaggedExpenses.at(0)).toEqual({transactionID: 't1', reportID: 'r1'}); + }); + + it('counts every non-report-field violation type that OpenApp returns', () => { + const report = createExpenseReport('r1'); + const transactions = [createTransaction('tA', 'r1'), createTransaction('tB', 'r1'), createTransaction('tC', 'r1')]; + const violations = buildViolations({ + tA: [{type: CONST.VIOLATION_TYPES.VIOLATION, name: CONST.VIOLATIONS.OVER_CATEGORY_LIMIT}], + tB: [{type: CONST.VIOLATION_TYPES.VIOLATION, name: CONST.VIOLATIONS.MISSING_ATTENDEES}], + tC: [{type: CONST.VIOLATION_TYPES.VIOLATION, name: CONST.VIOLATIONS.MISSING_COMMENT}], + }); + + const result = compute([buildReports(report), buildTransactions(...transactions), violations, createSession()], emptyContext); + + expect(result.flaggedExpenses).toHaveLength(3); + expect(result.flaggedExpenses.map((entry) => entry.transactionID).sort()).toEqual(['tA', 'tB', 'tC']); + }); + + it('excludes violations with showInReview === false', () => { + const report = createExpenseReport('r1'); + const transaction = createTransaction('t1', 'r1'); + const violations = buildViolations({ + t1: [{type: CONST.VIOLATION_TYPES.VIOLATION, name: CONST.VIOLATIONS.MISSING_CATEGORY, showInReview: false}], + }); + + const result = compute([buildReports(report), buildTransactions(transaction), violations, createSession()], emptyContext); + + expect(result.flaggedExpenses).toEqual([]); + }); + }); + + describe('Draft-report scope and resolution', () => { + it('excludes flagged transactions on submitted expense reports', () => { + const draftReport = createExpenseReport('r1'); + const submittedReport = createExpenseReport('r2', { + stateNum: CONST.REPORT.STATE_NUM.SUBMITTED, + statusNum: CONST.REPORT.STATUS_NUM.SUBMITTED, + }); + const draftTransaction = createTransaction('t1', 'r1'); + const submittedTransaction = createTransaction('t2', 'r2'); + const violations = buildViolations({ + t1: [{type: CONST.VIOLATION_TYPES.VIOLATION, name: CONST.VIOLATIONS.MISSING_CATEGORY}], + t2: [{type: CONST.VIOLATION_TYPES.VIOLATION, name: CONST.VIOLATIONS.MISSING_CATEGORY}], + }); + + const result = compute([buildReports(draftReport, submittedReport), buildTransactions(draftTransaction, submittedTransaction), violations, createSession()], emptyContext); + + expect(result.flaggedExpenses).toHaveLength(1); + expect(result.flaggedExpenses.at(0)).toEqual({transactionID: 't1', reportID: 'r1'}); + }); + + it('excludes flagged transactions whose parent report is not an expense report', () => { + const expenseReport = createExpenseReport('r1'); + const chatReport = createMockReport({ + reportID: 'r2', + type: CONST.REPORT.TYPE.CHAT, + ownerAccountID: CURRENT_USER_ACCOUNT_ID, + stateNum: CONST.REPORT.STATE_NUM.OPEN, + statusNum: CONST.REPORT.STATUS_NUM.OPEN, + }); + const t1 = createTransaction('t1', 'r1'); + const t2 = createTransaction('t2', 'r2'); + const violations = buildViolations({ + t1: [{type: CONST.VIOLATION_TYPES.VIOLATION, name: CONST.VIOLATIONS.MISSING_CATEGORY}], + t2: [{type: CONST.VIOLATION_TYPES.VIOLATION, name: CONST.VIOLATIONS.MISSING_CATEGORY}], + }); + + const result = compute([buildReports(expenseReport, chatReport), buildTransactions(t1, t2), violations, createSession()], emptyContext); + + expect(result.flaggedExpenses).toHaveLength(1); + expect(result.flaggedExpenses.at(0)).toEqual({transactionID: 't1', reportID: 'r1'}); + }); + + it('does not count report-field violations as flagged expenses', () => { + const report = createExpenseReport('r1'); + const transaction = createTransaction('t1', 'r1'); + const violations = buildViolations({ + t1: [{type: CONST.VIOLATION_TYPES.VIOLATION, name: CONST.REPORT_VIOLATIONS.FIELD_REQUIRED} as TransactionViolations[number]], + }); + + const result = compute([buildReports(report), buildTransactions(transaction), violations, createSession()], emptyContext); + + expect(result.flaggedExpenses).toEqual([]); + }); + + it('drops a transaction from the list when its last violation is cleared', () => { + const report = createExpenseReport('r1'); + const transaction = createTransaction('t1', 'r1'); + const flaggedViolations = buildViolations({ + t1: [{type: CONST.VIOLATION_TYPES.VIOLATION, name: CONST.VIOLATIONS.MISSING_CATEGORY}], + }); + + const flaggedResult = compute([buildReports(report), buildTransactions(transaction), flaggedViolations, createSession()], emptyContext); + expect(flaggedResult.flaggedExpenses).toHaveLength(1); + + const clearedViolations = buildViolations({t1: []}); + const clearedResult = compute([buildReports(report), buildTransactions(transaction), clearedViolations, createSession()], emptyContext); + expect(clearedResult.flaggedExpenses).toEqual([]); + }); + + it('restores a previously cleaned transaction once it is re-flagged', () => { + const report = createExpenseReport('r1'); + const transaction = createTransaction('t1', 'r1'); + + const clearedResult = compute([buildReports(report), buildTransactions(transaction), buildViolations({t1: []}), createSession()], emptyContext); + expect(clearedResult.flaggedExpenses).toEqual([]); + + const reflaggedResult = compute( + [ + buildReports(report), + buildTransactions(transaction), + buildViolations({ + t1: [{type: CONST.VIOLATION_TYPES.VIOLATION, name: CONST.VIOLATIONS.MISSING_CATEGORY}], + }), + createSession(), + ], + emptyContext, + ); + expect(reflaggedResult.flaggedExpenses).toHaveLength(1); + expect(reflaggedResult.flaggedExpenses.at(0)?.transactionID).toBe('t1'); + }); + + it('excludes flagged transactions on expense reports the current user does not own', () => { + const ownReport = createExpenseReport('r1'); + const foreignReport = createExpenseReport('r2', {ownerAccountID: OTHER_USER_ACCOUNT_ID}); + const ownTransaction = createTransaction('t1', 'r1'); + const foreignTransaction = createTransaction('t2', 'r2'); + const violations = buildViolations({ + t1: [{type: CONST.VIOLATION_TYPES.VIOLATION, name: CONST.VIOLATIONS.MISSING_CATEGORY}], + t2: [{type: CONST.VIOLATION_TYPES.VIOLATION, name: CONST.VIOLATIONS.MISSING_CATEGORY}], + }); + + const result = compute([buildReports(ownReport, foreignReport), buildTransactions(ownTransaction, foreignTransaction), violations, createSession()], emptyContext); + + expect(result.flaggedExpenses).toHaveLength(1); + expect(result.flaggedExpenses.at(0)).toEqual({transactionID: 't1', reportID: 'r1'}); + }); + + it('excludes all flagged transactions when the session has no accountID', () => { + const report = createExpenseReport('r1', {ownerAccountID: OTHER_USER_ACCOUNT_ID}); + const transaction = createTransaction('t1', 'r1'); + const violations = buildViolations({ + t1: [{type: CONST.VIOLATION_TYPES.VIOLATION, name: CONST.VIOLATIONS.MISSING_CATEGORY}], + }); + + const result = compute([buildReports(report), buildTransactions(transaction), violations, {} as Session], emptyContext); + + expect(result.flaggedExpenses).toEqual([]); + }); + }); +}); diff --git a/tests/unit/selectors/TodosTest.ts b/tests/unit/selectors/TodosTest.ts index 25e57801cd2a..9d90b76cb698 100644 --- a/tests/unit/selectors/TodosTest.ts +++ b/tests/unit/selectors/TodosTest.ts @@ -1,6 +1,6 @@ -import todosReportCountsSelector, {todosSingleReportIDsSelector} from '@selectors/Todos'; +import todosReportCountsSelector, {flaggedExpensesReviewSelector, todosSingleReportIDsSelector} from '@selectors/Todos'; import CONST from '@src/CONST'; -import type {TodosDerivedValue} from '@src/types/onyx'; +import type {FlaggedExpensesDerivedValue, TodosDerivedValue} from '@src/types/onyx'; describe('todosReportCountsSelector', () => { it('returns undefined when todos is undefined', () => { @@ -212,3 +212,69 @@ describe('todosSingleReportIDsSelector', () => { expect(second[CONST.SEARCH.SEARCH_KEYS.SUBMIT]).toBe('21'); }); }); + +describe('flaggedExpensesReviewSelector', () => { + it('returns count 0 and undefined identifiers when input is undefined', () => { + const result = flaggedExpensesReviewSelector(undefined); + + expect(result.count).toBe(0); + expect(result.firstTransactionID).toBeUndefined(); + expect(result.firstReportID).toBeUndefined(); + }); + + it('returns count 0 and undefined identifiers when there are no flagged expenses', () => { + const value: FlaggedExpensesDerivedValue = {flaggedExpenses: []}; + + const result = flaggedExpensesReviewSelector(value); + + expect(result.count).toBe(0); + expect(result.firstTransactionID).toBeUndefined(); + expect(result.firstReportID).toBeUndefined(); + }); + + it('returns aggregated count and stable first identifiers for multiple flagged expenses', () => { + const value: FlaggedExpensesDerivedValue = { + flaggedExpenses: [ + {transactionID: 't1', reportID: 'r1'}, + {transactionID: 't2', reportID: 'r2'}, + {transactionID: 't3', reportID: 'r3'}, + ], + }; + + const result = flaggedExpensesReviewSelector(value); + + expect(result.count).toBe(3); + expect(result.firstTransactionID).toBe('t1'); + expect(result.firstReportID).toBe('r1'); + }); + + it('returns the same object reference when called twice with shallowly equal values', () => { + const value: FlaggedExpensesDerivedValue = { + flaggedExpenses: [{transactionID: 'tA', reportID: 'rA'}], + }; + const first = flaggedExpensesReviewSelector(value); + + const clone: FlaggedExpensesDerivedValue = { + flaggedExpenses: [{transactionID: 'tA', reportID: 'rA'}], + }; + const second = flaggedExpensesReviewSelector(clone); + + expect(second).toBe(first); + }); + + it('returns a new object reference when the first flagged expense changes', () => { + const value: FlaggedExpensesDerivedValue = { + flaggedExpenses: [{transactionID: 'tA', reportID: 'rA'}], + }; + const first = flaggedExpensesReviewSelector(value); + + const changed: FlaggedExpensesDerivedValue = { + flaggedExpenses: [{transactionID: 'tB', reportID: 'rB'}], + }; + const second = flaggedExpensesReviewSelector(changed); + + expect(second).not.toBe(first); + expect(second.firstTransactionID).toBe('tB'); + expect(second.firstReportID).toBe('rB'); + }); +}); From e5867011838de160659f2e281b5c5376cfc9a855 Mon Sep 17 00:00:00 2001 From: jayeshmangwani Date: Fri, 29 May 2026 01:52:15 +0530 Subject: [PATCH 038/435] =?UTF-8?q?added=20a=20different=20CTA=20for=20tra?= =?UTF-8?q?ck=20intent=20users=20=E2=80=94=20=E2=80=9CMark=20as=20done?= =?UTF-8?q?=E2=80=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/CONST/index.ts | 1 + src/components/AnimatedSubmitButton/index.tsx | 7 +- .../OptionRowLHN/OptionRow/ErrorBadge.tsx | 8 +- .../OptionRowLHN/OptionRow/InfoBadge.tsx | 7 +- .../OptionRowLHN/OptionRow/Pressable.tsx | 8 +- .../OptionRowLHN/OptionRowLHN.tsx | 5 +- .../OptionRowLHN/OptionRowLHNData.tsx | 13 +- src/components/LHNOptionsList/types.ts | 2 + .../MoneyReportHeaderSelectionDropdown.tsx | 15 +- .../ApprovePrimaryAction.tsx | 12 +- .../SubmitPrimaryAction.tsx | 12 +- .../SubmitActionButton.tsx | 13 +- src/components/ReportWelcomeText.tsx | 6 + .../SearchList/ListItem/ActionCell/index.tsx | 16 +- src/components/utils/getActionBadgeText.ts | 11 ++ src/hooks/useSearchBulkActions.ts | 17 +- src/hooks/useSearchTypeMenuSections.ts | 6 +- src/languages/de.ts | 19 +++ src/languages/en.ts | 21 +++ src/languages/es.ts | 15 ++ src/languages/fr.ts | 19 +++ src/languages/it.ts | 19 +++ src/languages/ja.ts | 19 +++ src/languages/nl.ts | 19 +++ src/languages/pl.ts | 19 +++ src/languages/pt-BR.ts | 19 +++ src/languages/zh-hans.ts | 19 +++ src/libs/NextStepUtils.ts | 27 ++- src/libs/OptionsListUtils/index.ts | 11 +- src/libs/ReportUtils.ts | 13 ++ src/libs/SidebarUtils.ts | 12 ++ .../BaseReportActionContextMenu.tsx | 4 +- .../report/ContextMenu/ContextMenuActions.tsx | 13 +- .../inbox/report/PureReportActionItem.tsx | 5 + src/pages/inbox/report/ReportActionItem.tsx | 3 + .../actionContents/ActionContentRouter.tsx | 5 + .../actionContents/ApprovalFlowContent.tsx | 23 ++- src/selectors/Onboarding.ts | 10 +- tests/unit/OnboardingSelectorsTest.ts | 32 +++- tests/unit/ReportUtilsTest.ts | 134 +++++++++++++++ tests/unit/SidebarUtilsTest.ts | 158 ++++++++++++++++++ 41 files changed, 760 insertions(+), 37 deletions(-) create mode 100644 src/components/utils/getActionBadgeText.ts diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 202d637610f3..2fdbe033c4ff 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -1897,6 +1897,7 @@ const CONST = { MESSAGE_KEY: { WAITING_TO_ADD_TRANSACTIONS: 'waitingToAddTransactions', WAITING_TO_SUBMIT: 'waitingToSubmit', + WAITING_TO_MARK_AS_DONE: 'waitingToMarkAsDone', NO_FURTHER_ACTION: 'noFurtherAction', WAITING_FOR_SUBMITTER_ACCOUNT: 'waitingForSubmitterAccount', WAITING_FOR_AUTOMATIC_SUBMIT: 'waitingForAutomaticSubmit', diff --git a/src/components/AnimatedSubmitButton/index.tsx b/src/components/AnimatedSubmitButton/index.tsx index 945d5333fc9b..6e3e010aae1e 100644 --- a/src/components/AnimatedSubmitButton/index.tsx +++ b/src/components/AnimatedSubmitButton/index.tsx @@ -28,9 +28,12 @@ type AnimatedSubmitButtonProps = WithSentryLabel & { // Whether the button should be disabled isDisabled?: boolean; + + /** Whether to show "Mark as done" copy instead of "Submit" copy for track-intent users */ + isMarkAsDone?: boolean; }; -function AnimatedSubmitButton({success, text, onPress, isSubmittingAnimationRunning, onAnimationFinish, isDisabled, sentryLabel}: AnimatedSubmitButtonProps) { +function AnimatedSubmitButton({success, text, onPress, isSubmittingAnimationRunning, onAnimationFinish, isDisabled, sentryLabel, isMarkAsDone}: AnimatedSubmitButtonProps) { const styles = useThemeStyles(); const {translate} = useLocalize(); const isAnimationRunning = isSubmittingAnimationRunning; @@ -120,7 +123,7 @@ function AnimatedSubmitButton({success, text, onPress, isSubmittingAnimationRunn >
item.keyForList} compareItems={compareItems} isItemInSearch={isItemInSearch} initialSortColumn="name" title={translate('workspace.common.distanceRates')} - ListHeaderComponent={shouldUseNarrowTableLayout ? ListHeader : undefined} > - {!shouldUseNarrowTableLayout && ( - <> - {shouldShowSearchBar && } - - {SelectAllHeader} - - - - - - )} - + {shouldShowSearchBar && } +
); diff --git a/src/languages/de.ts b/src/languages/de.ts index d49ab9960341..f2f0b811f40e 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -6696,6 +6696,10 @@ _Für ausführlichere Anweisungen [besuchen Sie unsere Hilfeseite](${CONST.NETSU }), enableRate: 'Kurs aktivieren', status: 'Status', + statusActive: 'Aktiv', + statusFuture: 'Zukünftig', + statusExpired: 'Abgelaufen', + statusInactive: 'Inaktiv', unit: 'Einheit', taxFeatureNotEnabledMessage: 'Steuern müssen im Workspace aktiviert sein, um diese Funktion zu verwenden. Gehe zu Weitere Funktionen, um das zu ändern.', diff --git a/src/languages/es.ts b/src/languages/es.ts index 4ed11c0837eb..cce95beb0b18 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -6614,6 +6614,10 @@ ${amount} para ${merchant} - ${date}`, }), enableRate: 'Activar tasa', status: 'Estado', + statusActive: 'Activo', + statusFuture: 'Futuro', + statusExpired: 'Expirado', + statusInactive: 'Inactivo', unit: 'Unidad', taxFeatureNotEnabledMessage: 'Los impuestos deben estar activados en el área de trabajo para poder utilizar esta función. Dirígete a Más funcionalidades para hacer ese cambio.', diff --git a/src/languages/fr.ts b/src/languages/fr.ts index 81ed59d53e2f..6264820e731d 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -6723,6 +6723,10 @@ _Pour des instructions plus détaillées, [visitez notre site d’aide](${CONST. }), enableRate: 'Activer le taux', status: 'Statut', + statusActive: 'Actif', + statusFuture: 'Futur', + statusExpired: 'Expiré', + statusInactive: 'Inactif', unit: 'Unité', taxFeatureNotEnabledMessage: 'Les taxes doivent être activées sur l’espace de travail pour utiliser cette fonctionnalité. Rendez-vous dans Plus de fonctionnalités pour effectuer cette modification.', diff --git a/src/languages/it.ts b/src/languages/it.ts index f73777568947..e66bad25d034 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -6682,6 +6682,10 @@ _Per istruzioni più dettagliate, [visita il nostro sito di assistenza](${CONST. }), enableRate: 'Abilita tariffa', status: 'Stato', + statusActive: 'Attivo', + statusFuture: 'Futuro', + statusExpired: 'Scaduto', + statusInactive: 'Inattivo', unit: 'Unit', taxFeatureNotEnabledMessage: 'Per usare questa funzione devi abilitare le imposte nello spazio di lavoro. Vai su Altre funzionalità per effettuare questa modifica.', diff --git a/src/languages/ja.ts b/src/languages/ja.ts index 0de98845eab1..7b80100a123c 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -6611,6 +6611,10 @@ _詳しい手順については、[ヘルプサイトをご覧ください](${CO }), enableRate: 'レートを有効にする', status: 'ステータス', + statusActive: 'アクティブ', + statusFuture: '将来', + statusExpired: '期限切れ', + statusInactive: '無効', unit: '単位', taxFeatureNotEnabledMessage: 'この機能を利用するには、ワークスペースで税金設定を有効にする必要があります。設定を変更するには、その他の機能に移動してください。', diff --git a/src/languages/nl.ts b/src/languages/nl.ts index 0aba8236fe62..9fc65b00ffe5 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -6661,6 +6661,10 @@ _Voor meer gedetailleerde instructies, [bezoek onze help-site](${CONST.NETSUITE_ }), enableRate: 'Tarief inschakelen', status: 'Status', + statusActive: 'Actief', + statusFuture: 'Toekomstig', + statusExpired: 'Verlopen', + statusInactive: 'Inactief', unit: 'Eenheid', taxFeatureNotEnabledMessage: 'Belastingen moeten in de workspace zijn ingeschakeld om deze functie te gebruiken. Ga naar Meer functies om dat te wijzigen.', diff --git a/src/languages/pl.ts b/src/languages/pl.ts index 51fbc5014fc3..9aae731a4390 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -6654,6 +6654,10 @@ _Aby uzyskać bardziej szczegółowe instrukcje, [odwiedź naszą stronę pomocy }), enableRate: 'Włącz stawkę', status: 'Status', + statusActive: 'Aktywna', + statusFuture: 'Przyszła', + statusExpired: 'Wygasła', + statusInactive: 'Nieaktywna', unit: 'Jednostka', taxFeatureNotEnabledMessage: 'Aby korzystać z tej funkcji, włącz podatki w przestrzeni roboczej. Przejdź do sekcji Więcej funkcji, aby to zmienić.', diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index f8942f7398f1..803b70de82d2 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -6659,6 +6659,10 @@ _Para instruções mais detalhadas, [visite nossa central de ajuda](${CONST.NETS }), enableRate: 'Ativar tarifa', status: 'Status', + statusActive: 'Ativo', + statusFuture: 'Futuro', + statusExpired: 'Expirado', + statusInactive: 'Inativo', unit: 'Unidade', taxFeatureNotEnabledMessage: 'Os impostos precisam estar ativados no workspace para usar este recurso. Vá até Mais recursos para fazer essa alteração.', diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index 2a75e959f486..926083c1f2e6 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -6494,6 +6494,10 @@ _如需更详细的说明,请[访问我们的帮助网站](${CONST.NETSUITE_IM }), enableRate: '启用费率', status: '状态', + statusActive: '活跃', + statusFuture: '未来', + statusExpired: '已过期', + statusInactive: '未启用', unit: '单位', taxFeatureNotEnabledMessage: '必须在工作区中启用税费才能使用此功能。前往更多功能进行更改。', deleteDistanceRate: '删除距离费率', diff --git a/src/pages/workspace/distanceRates/PolicyDistanceRatesPage.tsx b/src/pages/workspace/distanceRates/PolicyDistanceRatesPage.tsx index 2691af12d89e..59a7843a1993 100644 --- a/src/pages/workspace/distanceRates/PolicyDistanceRatesPage.tsx +++ b/src/pages/workspace/distanceRates/PolicyDistanceRatesPage.tsx @@ -9,12 +9,6 @@ import {loadIllustration} from '@components/Icon/IllustrationLoader'; import type {IllustrationName} from '@components/Icon/IllustrationLoader'; import {ModalActions} from '@components/Modal/Global/ModalContext'; import ScreenWrapper from '@components/ScreenWrapper'; -import SearchBar from '@components/SearchBar'; -import TableListItem from '@components/SelectionList/ListItem/TableListItem'; -import type {ListItem} from '@components/SelectionList/types'; -import SelectionListWithModal from '@components/SelectionListWithModal'; -import CustomListHeader from '@components/SelectionListWithModal/CustomListHeader'; -import Switch from '@components/Switch'; import WorkspaceDistanceRatesTable from '@components/Tables/WorkspaceDistanceRatesTable'; import Text from '@components/Text'; import useConfirmModal from '@hooks/useConfirmModal'; @@ -24,17 +18,15 @@ import useLocalize from '@hooks/useLocalize'; import useMobileSelectionMode from '@hooks/useMobileSelectionMode'; import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; -import usePermissions from '@hooks/usePermissions'; import usePolicy from '@hooks/usePolicy'; import usePolicyFeatureWriteAccess from '@hooks/usePolicyFeatureWriteAccess'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useSearchBackPress from '@hooks/useSearchBackPress'; -import useSearchResults from '@hooks/useSearchResults'; import useShouldDisplayButtonsInSeparateLine from '@hooks/useShouldDisplayButtonsInSeparateLine'; import useThemeStyles from '@hooks/useThemeStyles'; import useTransactionViolation from '@hooks/useTransactionViolation'; import useWorkspaceDocumentTitle from '@hooks/useWorkspaceDocumentTitle'; -import {turnOffMobileSelectionMode, turnOnMobileSelectionMode} from '@libs/actions/MobileSelectionMode'; +import {turnOffMobileSelectionMode} from '@libs/actions/MobileSelectionMode'; import { clearCreateDistanceRateItemAndError, clearDeleteDistanceRateError, @@ -42,13 +34,10 @@ import { openPolicyDistanceRatesPage, setPolicyDistanceRatesEnabled, } from '@libs/actions/Policy/DistanceRate'; -import {convertAmountToDisplayString} from '@libs/CurrencyUtils'; -import {canUseTouchScreen} from '@libs/DeviceCapabilities'; import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; import {getDistanceRateCustomUnit} from '@libs/PolicyUtils'; import type {SkeletonSpanReasonAttributes} from '@libs/telemetry/useSkeletonSpan'; -import tokenizedSearch from '@libs/tokenizedSearch'; import type {WorkspaceSplitNavigatorParamList} from '@navigation/types'; import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper'; import ButtonWithDropdownMenu from '@src/components/ButtonWithDropdownMenu'; @@ -59,8 +48,6 @@ import type SCREENS from '@src/SCREENS'; import type {Report, Transaction} from '@src/types/onyx'; import type {Rate} from '@src/types/onyx/Policy'; -type RateForList = ListItem & {value: string; rate?: number}; - type PolicyDistanceRatesPageProps = PlatformStackScreenProps; function PolicyDistanceRatesPage({ @@ -69,18 +56,15 @@ function PolicyDistanceRatesPage({ }, }: PolicyDistanceRatesPageProps) { const icons = useMemoizedLazyExpensifyIcons(['Checkmark', 'Close', 'Gear', 'Plus', 'Trashcan']); - const {isBetaEnabled} = usePermissions(); - const isDateBoundMileageRateEnabled = isBetaEnabled(CONST.BETAS.DATE_BOUND_MILEAGE_RATE); const {shouldUseNarrowLayout, isInLandscapeMode} = useResponsiveLayout(); const styles = useThemeStyles(); - const {translate, localeCompare} = useLocalize(); + const {translate} = useLocalize(); const {showConfirmModal} = useConfirmModal(); const policy = usePolicy(policyID); useWorkspaceDocumentTitle(policy?.name, 'workspace.common.distanceRates'); const isMobileSelectionModeEnabled = useMobileSelectionMode(); - const {canWrite: canWriteDistanceRates, showReadOnlyModal, withReadOnlyFallback} = usePolicyFeatureWriteAccess(policy, CONST.POLICY.POLICY_FEATURE.DISTANCE_RATES); + const {canWrite: canWriteDistanceRates, showReadOnlyModal} = usePolicyFeatureWriteAccess(policy, CONST.POLICY.POLICY_FEATURE.DISTANCE_RATES); - const canSelectMultiple = canWriteDistanceRates && (shouldUseNarrowLayout ? isMobileSelectionModeEnabled : true); const {asset: CarIce} = useMemoizedLazyAsset(() => loadIllustration('CarIce' as IllustrationName)); const customUnit = useMemo(() => getDistanceRateCustomUnit(policy), [policy]); const customUnitRates: Record = useMemo(() => customUnit?.rates ?? {}, [customUnit?.rates]); @@ -167,22 +151,6 @@ function PolicyDistanceRatesPage({ openPolicyDistanceRatesPage(policyID); }, [policyID]); - const dismissError = useCallback( - (item: RateForList) => { - if (!customUnit?.customUnitID) { - return; - } - - if (customUnitRates[item.value].errors) { - clearDeleteDistanceRateError(policyID, customUnit.customUnitID, item.value); - return; - } - - clearCreateDistanceRateItemAndError(policyID, customUnit.customUnitID, item.value); - }, - [customUnit?.customUnitID, customUnitRates, policyID], - ); - const {isOffline} = useNetwork({onReconnect: fetchDistanceRates}); useEffect(() => { @@ -223,7 +191,6 @@ function PolicyDistanceRatesPage({ return; } const rate = customUnit?.rates?.[rateID]; - // Rates can be disabled or deleted as long as in the remaining rates there is always at least one enabled rate and there are no pending delete actions if (!rate?.enabled || canDisableOrDeleteRate(rateID)) { setPolicyDistanceRatesEnabled(policyID, customUnit, [{...rate, enabled: value}]); } else { @@ -235,60 +202,6 @@ function PolicyDistanceRatesPage({ const unitTranslation = translate(`common.${customUnit?.attributes?.unit ?? CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES}`); - const distanceRatesList = useMemo( - () => - Object.values(customUnitRates).map((value) => { - const alternateText = `${convertAmountToDisplayString(value.rate, value.currency ?? CONST.CURRENCY.USD)} / ${unitTranslation}`; - - return { - rate: value.rate, - value: value.customUnitRateID, - text: value.name, - alternateText, - keyForList: value.customUnitRateID, - isDisabled: value.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE, - pendingAction: - value.pendingAction ?? - value.pendingFields?.rate ?? - value.pendingFields?.enabled ?? - value.pendingFields?.currency ?? - value.pendingFields?.taxRateExternalID ?? - value.pendingFields?.taxClaimablePercentage ?? - value.pendingFields?.name ?? - customUnit?.pendingFields?.attributes ?? - (policy?.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD ? policy?.pendingAction : undefined), - errors: value.errors ?? undefined, - rightElement: ( - updateDistanceRateEnabled(newValue, value.customUnitRateID)} - showLockIcon={!canWriteDistanceRates || !canDisableOrDeleteRate(value.customUnitRateID)} - disabled={!canWriteDistanceRates || value.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE} - disabledAction={withReadOnlyFallback()} - /> - ), - }; - }), - [ - canDisableOrDeleteRate, - canWriteDistanceRates, - customUnitRates, - unitTranslation, - customUnit?.pendingFields?.attributes, - policy?.pendingAction, - withReadOnlyFallback, - updateDistanceRateEnabled, - ], - ); - - const filterRate = useCallback((rate: RateForList, searchInput: string) => { - const results = tokenizedSearch([rate], searchInput, (option) => [option.text ?? '']); - return results.length > 0; - }, []); - const sortRates = useCallback((rates: RateForList[]) => rates.sort((a, b) => localeCompare(a.text ?? '', b.text ?? '')), [localeCompare]); - const [inputValue, setInputValue, filteredDistanceRatesList] = useSearchResults(distanceRatesList, filterRate, sortRates); - const addRate = () => { Navigation.navigate(ROUTES.WORKSPACE_CREATE_DISTANCE_RATE.getRoute(policyID)); }; @@ -297,10 +210,6 @@ function PolicyDistanceRatesPage({ Navigation.navigate(ROUTES.WORKSPACE_DISTANCE_RATES_SETTINGS.getRoute(policyID)); }, [policyID]); - const openRateDetails = (rate: RateForList) => { - Navigation.navigate(ROUTES.WORKSPACE_DISTANCE_RATE_DETAILS.getRoute(policyID, rate.value)); - }; - const disableRates = () => { if (customUnit === undefined) { return; @@ -344,57 +253,20 @@ function PolicyDistanceRatesPage({ setSelectedDistanceRates([]); }; - const toggleRateByID = useCallback( + const dismissErrorByID = useCallback( (rateID: string) => { - setSelectedDistanceRates((prevSelectedRates) => { - if (prevSelectedRates.includes(rateID)) { - return prevSelectedRates.filter((selectedRate) => selectedRate !== rateID); - } - return [...prevSelectedRates, rateID]; - }); + if (!customUnit?.customUnitID) { + return; + } + if (customUnitRates[rateID]?.errors) { + clearDeleteDistanceRateError(policyID, customUnit.customUnitID, rateID); + return; + } + clearCreateDistanceRateItemAndError(policyID, customUnit.customUnitID, rateID); }, - [setSelectedDistanceRates], + [customUnit?.customUnitID, customUnitRates, policyID], ); - const toggleRate = (rate: RateForList) => { - toggleRateByID(rate.value); - }; - - const toggleAllRates = () => { - if (selectedDistanceRates.length > 0) { - setSelectedDistanceRates([]); - } else { - setSelectedDistanceRates( - Object.entries(selectableRates) - .filter(([, rate]) => rate.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE && filteredDistanceRatesList.some((item) => item.value === rate.customUnitRateID)) - .map(([key]) => key), - ); - } - }; - - const toggleAllRatesForTable = useCallback(() => { - if (selectedDistanceRates.length > 0) { - setSelectedDistanceRates([]); - } else { - setSelectedDistanceRates( - Object.entries(selectableRates) - .filter(([, rate]) => rate.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE) - .map(([key]) => key), - ); - } - }, [selectedDistanceRates.length, selectableRates, setSelectedDistanceRates]); - - const dismissErrorByID = (rateID: string) => { - if (!customUnit?.customUnitID) { - return; - } - if (customUnitRates[rateID]?.errors) { - clearDeleteDistanceRateError(policyID, customUnit.customUnitID, rateID); - return; - } - clearCreateDistanceRateItemAndError(policyID, customUnit.customUnitID, rateID); - }; - const openRateDetailsByID = useCallback( (rateID: string) => { Navigation.navigate(ROUTES.WORKSPACE_DISTANCE_RATE_DETAILS.getRoute(policyID, rateID)); @@ -402,30 +274,6 @@ function PolicyDistanceRatesPage({ [policyID], ); - const handleLongPressRate = useCallback( - (rateID: string) => { - if (shouldUseNarrowLayout && !isMobileSelectionModeEnabled) { - turnOnMobileSelectionMode(); - } - toggleRateByID(rateID); - }, - [shouldUseNarrowLayout, isMobileSelectionModeEnabled, toggleRateByID], - ); - - const getCustomListHeader = () => { - if (filteredDistanceRatesList.length === 0) { - return null; - } - return ( - - ); - }; - const getBulkActionsButtonOptions = () => { const options: Array> = [ { @@ -534,24 +382,6 @@ function PolicyDistanceRatesPage({ const selectionModeHeader = isMobileSelectionModeEnabled && shouldUseNarrowLayout; - const headerContent = ( - <> - {Object.values(customUnitRates).length > 0 && ( - - {translate('workspace.distanceRates.centrallyManage')} - - )} - {Object.values(customUnitRates).length >= CONST.STANDARD_LIST_ITEM_LIMIT && ( - - )} - - ); - return ( )} - {Object.values(customUnitRates).length > 0 && - (isDateBoundMileageRateEnabled ? ( + {Object.values(customUnitRates).length > 0 && ( + <> + + {translate('workspace.distanceRates.centrallyManage')} + - ) : ( - item && canWriteDistanceRates && toggleRate(item)} - onSelectAll={canWriteDistanceRates && filteredDistanceRatesList.length > 0 ? toggleAllRates : undefined} - shouldPreventDefaultFocusOnSelectRow={!canUseTouchScreen()} - customListHeaderContent={headerContent} - canSelectMultiple={canSelectMultiple} - selectAllAccessibilityLabel={translate('accessibilityHints.selectAllDistanceRates')} - onDismissError={dismissError} - shouldShowListEmptyContent={false} - showScrollIndicator={false} - turnOnSelectionModeOnLongPress={canWriteDistanceRates} - shouldHeaderBeInsideList - shouldShowRightCaret - /> - ))} + + )} ); diff --git a/src/styles/variables.ts b/src/styles/variables.ts index 0e9eaee8ec44..4f7043f0d272 100644 --- a/src/styles/variables.ts +++ b/src/styles/variables.ts @@ -135,6 +135,7 @@ export default { tableGroupRowHeight: 36, tableSkeletonHeight: 32, tableCheckboxColumnWidth: 20, + tableStatusColumnWidth: 60, tableSwitchColumnWidth: 58, tableCaretColumnWidth: 20, domainTableActionColumnWidth: 64, From 8eecdb40cb270d88e5c8d933a04364a6d5a7c525 Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Wed, 10 Jun 2026 04:47:58 +0530 Subject: [PATCH 243/435] Use StatusBadge for distance rate status column with themed colors Signed-off-by: krishna2323 --- .../WorkspaceDistanceRatesTableRow.tsx | 32 ++++++++++++++++--- 1 file changed, 27 insertions(+), 5 deletions(-) diff --git a/src/components/Tables/WorkspaceDistanceRatesTable/WorkspaceDistanceRatesTableRow.tsx b/src/components/Tables/WorkspaceDistanceRatesTable/WorkspaceDistanceRatesTableRow.tsx index a3c070995746..42c7ddcfaa55 100644 --- a/src/components/Tables/WorkspaceDistanceRatesTable/WorkspaceDistanceRatesTableRow.tsx +++ b/src/components/Tables/WorkspaceDistanceRatesTable/WorkspaceDistanceRatesTableRow.tsx @@ -1,7 +1,9 @@ import {format, parseISO} from 'date-fns'; -import React from 'react'; +import React, {useMemo} from 'react'; +import type {ColorValue} from 'react-native'; import {View} from 'react-native'; import Icon from '@components/Icon'; +import StatusBadge from '@components/StatusBadge'; import Switch from '@components/Switch'; import Table from '@components/Table'; import type {TableData} from '@components/Table'; @@ -43,6 +45,23 @@ function formatDateColumn(dateString: string | null | undefined): string { return format(parseISO(dateString), CONST.DATE.MONTH_DAY_YEAR_FORMAT); } +function useRateStatusColors(status: string): {backgroundColor: ColorValue; textColor: ColorValue} { + const theme = useTheme(); + return useMemo(() => { + switch (status) { + case CONST.CUSTOM_UNITS.RATE_STATUS.ACTIVE: + return theme.reportStatusBadge.paid; + case CONST.CUSTOM_UNITS.RATE_STATUS.FUTURE: + return theme.reportStatusBadge.draft; + case CONST.CUSTOM_UNITS.RATE_STATUS.EXPIRED: + return theme.reportStatusBadge.outstanding; + case CONST.CUSTOM_UNITS.RATE_STATUS.INACTIVE: + default: + return {backgroundColor: theme.badgeDefaultBG, textColor: theme.text}; + } + }, [status, theme]); +} + function WorkspaceDistanceRatesTableRow({item, rowIndex, shouldUseNarrowTableLayout, statusLabels}: WorkspaceDistanceRatesTableRowProps) { const theme = useTheme(); const styles = useThemeStyles(); @@ -52,6 +71,7 @@ function WorkspaceDistanceRatesTableRow({item, rowIndex, shouldUseNarrowTableLay const isDeleting = pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE; const status = getRateStatus(rate); + const statusColors = useRateStatusColors(status); const reasonAttributes: SkeletonSpanReasonAttributes = { context: 'WorkspaceDistanceRatesTableItem', @@ -70,15 +90,17 @@ function WorkspaceDistanceRatesTableRow({item, rowIndex, shouldUseNarrowTableLay > {({hovered}) => ( <> - - + {shouldUseNarrowTableLayout && ( )} From bfc74dd0c91b2b11fe40b0da7393ac93327961b6 Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Wed, 10 Jun 2026 04:55:39 +0530 Subject: [PATCH 244/435] Use StatusBadge with themed colors for distance rate status column Signed-off-by: krishna2323 --- .../WorkspaceDistanceRatesTableRow.tsx | 1 - .../Tables/WorkspaceDistanceRatesTable/index.tsx | 8 +++++++- src/styles/variables.ts | 2 +- 3 files changed, 8 insertions(+), 3 deletions(-) diff --git a/src/components/Tables/WorkspaceDistanceRatesTable/WorkspaceDistanceRatesTableRow.tsx b/src/components/Tables/WorkspaceDistanceRatesTable/WorkspaceDistanceRatesTableRow.tsx index 42c7ddcfaa55..17d09cffb159 100644 --- a/src/components/Tables/WorkspaceDistanceRatesTable/WorkspaceDistanceRatesTableRow.tsx +++ b/src/components/Tables/WorkspaceDistanceRatesTable/WorkspaceDistanceRatesTableRow.tsx @@ -95,7 +95,6 @@ function WorkspaceDistanceRatesTableRow({item, rowIndex, shouldUseNarrowTableLay text={statusLabels[status] ?? ''} backgroundColor={statusColors.backgroundColor} textColor={statusColors.textColor} - badgeStyles={styles.alignSelfStart} /> {shouldUseNarrowTableLayout && ( >(null); const columns: Array> = [ - {key: 'status', label: translate('workspace.distanceRates.status'), sortable: true, width: variables.tableStatusColumnWidth}, + { + key: 'status', + label: translate('workspace.distanceRates.status'), + sortable: true, + width: variables.tableStatusColumnWidth, + styling: {containerStyles: [styles.justifyContentCenter]}, + }, {key: 'name', label: translate('common.name'), sortable: true}, {key: 'rate', label: translate('workspace.distanceRates.rate'), sortable: true}, {key: 'startDate', label: translate('workspace.distanceRates.startDate'), sortable: true}, diff --git a/src/styles/variables.ts b/src/styles/variables.ts index 4f7043f0d272..440983320d34 100644 --- a/src/styles/variables.ts +++ b/src/styles/variables.ts @@ -135,7 +135,7 @@ export default { tableGroupRowHeight: 36, tableSkeletonHeight: 32, tableCheckboxColumnWidth: 20, - tableStatusColumnWidth: 60, + tableStatusColumnWidth: 48, tableSwitchColumnWidth: 58, tableCaretColumnWidth: 20, domainTableActionColumnWidth: 64, From 40e7552d534cf44b2150a55f872326dd031b77bd Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Wed, 10 Jun 2026 05:02:21 +0530 Subject: [PATCH 245/435] Use StatusBadge for distance rate status and update narrow layout Signed-off-by: krishna2323 --- .../WorkspaceDistanceRatesTableRow.tsx | 39 +++++++++++++------ 1 file changed, 28 insertions(+), 11 deletions(-) diff --git a/src/components/Tables/WorkspaceDistanceRatesTable/WorkspaceDistanceRatesTableRow.tsx b/src/components/Tables/WorkspaceDistanceRatesTable/WorkspaceDistanceRatesTableRow.tsx index 17d09cffb159..b26ec28a6aec 100644 --- a/src/components/Tables/WorkspaceDistanceRatesTable/WorkspaceDistanceRatesTableRow.tsx +++ b/src/components/Tables/WorkspaceDistanceRatesTable/WorkspaceDistanceRatesTableRow.tsx @@ -90,19 +90,36 @@ function WorkspaceDistanceRatesTableRow({item, rowIndex, shouldUseNarrowTableLay > {({hovered}) => ( <> - - - {shouldUseNarrowTableLayout && ( + {shouldUseNarrowTableLayout && ( + - )} - + + + + + + )} + + {!shouldUseNarrowTableLayout && ( + + + + )} {!shouldUseNarrowTableLayout && ( From 427022b1fbe0da36147e7a24b310cd5bc95aacc4 Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Wed, 10 Jun 2026 05:18:38 +0530 Subject: [PATCH 246/435] Use StatusBadge and shared getRateDateLabel for distance rate status and date display Signed-off-by: krishna2323 --- .../WorkspaceDistanceRatesTableRow.tsx | 14 +++++++---- src/languages/de.ts | 3 +++ src/languages/en.ts | 3 +++ src/languages/es.ts | 3 +++ src/languages/fr.ts | 4 ++- src/languages/it.ts | 3 +++ src/languages/ja.ts | 3 +++ src/languages/nl.ts | 3 +++ src/languages/pl.ts | 3 +++ src/languages/pt-BR.ts | 3 +++ src/languages/zh-hans.ts | 3 +++ src/libs/DistanceRequestUtils.ts | 25 +++++++++++++++++++ 12 files changed, 64 insertions(+), 6 deletions(-) diff --git a/src/components/Tables/WorkspaceDistanceRatesTable/WorkspaceDistanceRatesTableRow.tsx b/src/components/Tables/WorkspaceDistanceRatesTable/WorkspaceDistanceRatesTableRow.tsx index b26ec28a6aec..6ee5166c2a39 100644 --- a/src/components/Tables/WorkspaceDistanceRatesTable/WorkspaceDistanceRatesTableRow.tsx +++ b/src/components/Tables/WorkspaceDistanceRatesTable/WorkspaceDistanceRatesTableRow.tsx @@ -9,8 +9,10 @@ import Table from '@components/Table'; import type {TableData} from '@components/Table'; import TextWithTooltip from '@components/TextWithTooltip'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; +import useLocalize from '@hooks/useLocalize'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; +import DistanceRequestUtils from '@libs/DistanceRequestUtils'; import {getRateStatus} from '@libs/PolicyDistanceRatesUtils'; import type {SkeletonSpanReasonAttributes} from '@libs/telemetry/useSkeletonSpan'; import variables from '@styles/variables'; @@ -38,11 +40,11 @@ type WorkspaceDistanceRatesTableRowProps = { statusLabels: Record; }; -function formatDateColumn(dateString: string | null | undefined): string { +function formatDate(dateString: string | null | undefined): string { if (!dateString) { return ''; } - return format(parseISO(dateString), CONST.DATE.MONTH_DAY_YEAR_FORMAT); + return format(parseISO(dateString), CONST.DATE.MONTH_DAY_YEAR_ABBR_FORMAT); } function useRateStatusColors(status: string): {backgroundColor: ColorValue; textColor: ColorValue} { @@ -65,6 +67,7 @@ function useRateStatusColors(status: string): {backgroundColor: ColorValue; text function WorkspaceDistanceRatesTableRow({item, rowIndex, shouldUseNarrowTableLayout, statusLabels}: WorkspaceDistanceRatesTableRowProps) { const theme = useTheme(); const styles = useThemeStyles(); + const {translate} = useLocalize(); const Expensicons = useMemoizedLazyExpensifyIcons(['ArrowRight']); const {rate, formattedRate, pendingAction, errors} = item; @@ -72,6 +75,7 @@ function WorkspaceDistanceRatesTableRow({item, rowIndex, shouldUseNarrowTableLay const status = getRateStatus(rate); const statusColors = useRateStatusColors(status); + const dateLabelText = DistanceRequestUtils.getRateDateLabel({...rate, unit: CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES}, translate); const reasonAttributes: SkeletonSpanReasonAttributes = { context: 'WorkspaceDistanceRatesTableItem', @@ -103,7 +107,7 @@ function WorkspaceDistanceRatesTableRow({item, rowIndex, shouldUseNarrowTableLay textColor={statusColors.textColor} /> @@ -145,7 +149,7 @@ function WorkspaceDistanceRatesTableRow({item, rowIndex, shouldUseNarrowTableLay @@ -155,7 +159,7 @@ function WorkspaceDistanceRatesTableRow({item, rowIndex, shouldUseNarrowTableLay diff --git a/src/languages/de.ts b/src/languages/de.ts index f2f0b811f40e..d49ffadc36ce 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -1565,6 +1565,9 @@ const translations: TranslationDeepObject = { removed: 'entfernt', transactionPending: 'Transaktion ausstehend.', chooseARate: 'Wähle einen Rückerstattungssatz pro Meile oder Kilometer für den Workspace aus', + rateValidDateRange: ({startDate, endDate}: {startDate: string; endDate: string}) => `${startDate} bis ${endDate}`, + rateValidFrom: ({startDate}: {startDate: string}) => `Gültig ab ${startDate}`, + rateValidUntil: ({endDate}: {endDate: string}) => `Gültig bis ${endDate}`, unapprove: 'Genehmigung aufheben', unapproveReport: 'Bericht ablehnen', headsUp: 'Achtung!', diff --git a/src/languages/en.ts b/src/languages/en.ts index fb48ccc800dd..27fe5697339f 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -1625,6 +1625,9 @@ const translations = { removed: 'removed', transactionPending: 'Transaction pending.', chooseARate: 'Select a workspace reimbursement rate per mile or kilometer', + rateValidDateRange: ({startDate, endDate}: {startDate: string; endDate: string}) => `${startDate} to ${endDate}`, + rateValidFrom: ({startDate}: {startDate: string}) => `Valid from ${startDate}`, + rateValidUntil: ({endDate}: {endDate: string}) => `Valid until ${endDate}`, unapprove: 'Unapprove', unapproveReport: 'Unapprove report', headsUp: 'Heads up!', diff --git a/src/languages/es.ts b/src/languages/es.ts index cce95beb0b18..1469f6416268 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -1531,6 +1531,9 @@ const translations: TranslationDeepObject = { removed: 'eliminó', transactionPending: 'Transacción pendiente.', chooseARate: 'Selecciona una tasa de reembolso por milla o kilómetro para el espacio de trabajo', + rateValidDateRange: ({startDate, endDate}: {startDate: string; endDate: string}) => `${startDate} a ${endDate}`, + rateValidFrom: ({startDate}: {startDate: string}) => `Válido desde ${startDate}`, + rateValidUntil: ({endDate}: {endDate: string}) => `Válido hasta ${endDate}`, unapprove: 'Desaprobar', unapproveReport: 'Anular la aprobación del informe', headsUp: 'Atención!', diff --git a/src/languages/fr.ts b/src/languages/fr.ts index 6264820e731d..c32859be3bf5 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -1,7 +1,6 @@ /** * _____ __ __ * / ___/__ ___ ___ _______ _/ /____ ___/ / - * / (_ / -_) _ \/ -_) __/ _ \`/ __/ -_) _ / * \___/\__/_//_/\__/_/ \_,_/\__/\__/\_,_/ * * This file was automatically generated. Please consider these alternatives before manually editing it: @@ -1570,6 +1569,9 @@ const translations: TranslationDeepObject = { removed: 'supprimé', transactionPending: 'Transaction en attente.', chooseARate: 'Sélectionnez un taux de remboursement par mile ou kilomètre pour l’espace de travail', + rateValidDateRange: ({startDate, endDate}: {startDate: string; endDate: string}) => `${startDate} au ${endDate}`, + rateValidFrom: ({startDate}: {startDate: string}) => `Valide à partir du ${startDate}`, + rateValidUntil: ({endDate}: {endDate: string}) => `Valide jusqu’au ${endDate}`, unapprove: 'Annuler l’approbation', unapproveReport: 'Retirer l’approbation de la note de frais', headsUp: 'Attention !', diff --git a/src/languages/it.ts b/src/languages/it.ts index e66bad25d034..7d4d7d83e267 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -1564,6 +1564,9 @@ const translations: TranslationDeepObject = { removed: 'rimosso', transactionPending: 'Transazione in sospeso.', chooseARate: 'Seleziona una tariffa di rimborso per miglio o chilometro per lo spazio di lavoro', + rateValidDateRange: ({startDate, endDate}: {startDate: string; endDate: string}) => `${startDate} al ${endDate}`, + rateValidFrom: ({startDate}: {startDate: string}) => `Valido dal ${startDate}`, + rateValidUntil: ({endDate}: {endDate: string}) => `Valido fino al ${endDate}`, unapprove: 'Revoca approvazione', unapproveReport: 'Annulla approvazione rapporto', headsUp: 'Attenzione!', diff --git a/src/languages/ja.ts b/src/languages/ja.ts index 7b80100a123c..0d077ecf784b 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -1548,6 +1548,9 @@ const translations: TranslationDeepObject = { removed: '削除済み', transactionPending: '取引は保留中です。', chooseARate: 'ワークスペースの払い戻しレートをマイルまたはキロメートルごとに選択してください', + rateValidDateRange: ({startDate, endDate}: {startDate: string; endDate: string}) => `${startDate}から${endDate}まで有効`, + rateValidFrom: ({startDate}: {startDate: string}) => `${startDate}から有効`, + rateValidUntil: ({endDate}: {endDate: string}) => `${endDate}まで有効`, unapprove: '承認を取り消す', unapproveReport: 'レポートの承認を取り消す', headsUp: 'ご注意ください!', diff --git a/src/languages/nl.ts b/src/languages/nl.ts index 9fc65b00ffe5..a019dc0961ba 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -1560,6 +1560,9 @@ const translations: TranslationDeepObject = { removed: 'verwijderd', transactionPending: 'Transactie in behandeling.', chooseARate: 'Selecteer een workspace-vergoeding per mijl of kilometer', + rateValidDateRange: ({startDate, endDate}: {startDate: string; endDate: string}) => `${startDate} tot ${endDate}`, + rateValidFrom: ({startDate}: {startDate: string}) => `Geldig vanaf ${startDate}`, + rateValidUntil: ({endDate}: {endDate: string}) => `Geldig tot ${endDate}`, unapprove: 'Intrekken goedkeuring', unapproveReport: 'Goedkeuring rapport intrekken', headsUp: 'Let op!', diff --git a/src/languages/pl.ts b/src/languages/pl.ts index 9aae731a4390..756d71f4834b 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -1559,6 +1559,9 @@ const translations: TranslationDeepObject = { removed: 'usunięto', transactionPending: 'Transakcja w toku.', chooseARate: 'Wybierz stawkę zwrotu kosztów za milę lub kilometr dla przestrzeni roboczej', + rateValidDateRange: ({startDate, endDate}: {startDate: string; endDate: string}) => `${startDate} do ${endDate}`, + rateValidFrom: ({startDate}: {startDate: string}) => `Ważne od ${startDate}`, + rateValidUntil: ({endDate}: {endDate: string}) => `Ważne do ${endDate}`, unapprove: 'Cofnij zatwierdzenie', unapproveReport: 'Cofnij zatwierdzenie raportu', headsUp: 'Uwaga!', diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index 803b70de82d2..d48fbd3ddb68 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -1558,6 +1558,9 @@ const translations: TranslationDeepObject = { removed: 'removido', transactionPending: 'Transação pendente.', chooseARate: 'Selecione uma taxa de reembolso do espaço de trabalho por milha ou quilômetro', + rateValidDateRange: ({startDate, endDate}: {startDate: string; endDate: string}) => `${startDate} a ${endDate}`, + rateValidFrom: ({startDate}: {startDate: string}) => `Válido a partir de ${startDate}`, + rateValidUntil: ({endDate}: {endDate: string}) => `Válido até ${endDate}`, unapprove: 'Reprovar', unapproveReport: 'Rejeitar relatório', headsUp: 'Atenção!', diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index 926083c1f2e6..da09653bea09 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -1515,6 +1515,9 @@ const translations: TranslationDeepObject = { removed: '已移除', transactionPending: '交易处理中。', chooseARate: '选择工作区每英里或每公里的报销费率', + rateValidDateRange: ({startDate, endDate}: {startDate: string; endDate: string}) => `${startDate} 至 ${endDate}`, + rateValidFrom: ({startDate}: {startDate: string}) => `有效期自 ${startDate}`, + rateValidUntil: ({endDate}: {endDate: string}) => `有效期至 ${endDate}`, unapprove: '取消批准', unapproveReport: '取消批准报销单', headsUp: '注意!', diff --git a/src/libs/DistanceRequestUtils.ts b/src/libs/DistanceRequestUtils.ts index b08017bf6e62..81d0f5948104 100644 --- a/src/libs/DistanceRequestUtils.ts +++ b/src/libs/DistanceRequestUtils.ts @@ -1,3 +1,4 @@ +import {format, parseISO} from 'date-fns'; import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; import Onyx from 'react-native-onyx'; import type {CurrencyListActionsContextType} from '@components/CurrencyListContextProvider'; @@ -639,6 +640,29 @@ function prepareTextForDisplay(text: string): string { return text.replaceAll(/[^0-9., ]/g, '').replace(/^0+(?=\d)/, ''); } +function getRateDateLabel(rate: MileageRate, translate: LocaleContextProps['translate']): string { + const dateFormat = CONST.DATE.MONTH_DAY_YEAR_ABBR_FORMAT; + + try { + if (rate.startDate && rate.endDate) { + return translate('iou.rateValidDateRange', { + startDate: format(parseISO(rate.startDate), dateFormat), + endDate: format(parseISO(rate.endDate), dateFormat), + }); + } + if (rate.startDate) { + return translate('iou.rateValidFrom', {startDate: format(parseISO(rate.startDate), dateFormat)}); + } + if (rate.endDate) { + return translate('iou.rateValidUntil', {endDate: format(parseISO(rate.endDate), dateFormat)}); + } + } catch { + return ''; + } + + return ''; +} + export default { getDefaultMileageRate, getDistanceMerchant, @@ -663,6 +687,7 @@ export default { prepareTextForDisplay, isRateEligibleForDate, getBestEligibleRate, + getRateDateLabel, }; export type {MileageRate}; From 45570b339e4cc215ee155f8b44a6e1bf07bee209 Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Wed, 10 Jun 2026 05:22:49 +0530 Subject: [PATCH 247/435] fix knip check. Signed-off-by: krishna2323 --- src/components/Tables/WorkspaceDistanceRatesTable/index.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/src/components/Tables/WorkspaceDistanceRatesTable/index.tsx b/src/components/Tables/WorkspaceDistanceRatesTable/index.tsx index 7d607785a754..a4b67d42f699 100644 --- a/src/components/Tables/WorkspaceDistanceRatesTable/index.tsx +++ b/src/components/Tables/WorkspaceDistanceRatesTable/index.tsx @@ -222,4 +222,3 @@ function WorkspaceDistanceRatesTable({ } export default WorkspaceDistanceRatesTable; -export type {DistanceRateTableItemData, DistanceRatesTableColumnKey}; From 42da6c4f8b30e28477edbc5568b8cd2ccf805cb9 Mon Sep 17 00:00:00 2001 From: Rayane <77965000+rayane-d@users.noreply.github.com> Date: Wed, 10 Jun 2026 01:02:53 +0100 Subject: [PATCH 248/435] Bump eslint-seatbelt baseline for category-tax-rate type assertions, we do not refactor them to stay consistent with the identical pattern used by every sibling change-log getter --- config/eslint/eslint.seatbelt.tsv | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/config/eslint/eslint.seatbelt.tsv b/config/eslint/eslint.seatbelt.tsv index dd8ac0e99af4..0112396379be 100644 --- a/config/eslint/eslint.seatbelt.tsv +++ b/config/eslint/eslint.seatbelt.tsv @@ -760,7 +760,7 @@ "../../src/libs/ReportActionItemEventHandler/index.android.ts" "@typescript-eslint/no-deprecated/InteractionManager.runAfterInteractions" 1 "../../src/libs/ReportActionsUtils.ts" "@typescript-eslint/no-deprecated/reportAction.sequenceNumber" 1 "../../src/libs/ReportActionsUtils.ts" "@typescript-eslint/no-deprecated/reportAction?.originalMessage" 2 -"../../src/libs/ReportActionsUtils.ts" "@typescript-eslint/no-unsafe-type-assertion" 110 +"../../src/libs/ReportActionsUtils.ts" "@typescript-eslint/no-unsafe-type-assertion" 111 "../../src/libs/ReportActionsUtils.ts" "rulesdir/no-onyx-connect" 3 "../../src/libs/ReportNameUtils.ts" "@typescript-eslint/no-deprecated/translateLocal" 12 "../../src/libs/ReportNameUtils.ts" "@typescript-eslint/no-unsafe-type-assertion" 2 @@ -1828,7 +1828,7 @@ "../../tests/unit/ReportActionsListPaddingViewTest.tsx" "@typescript-eslint/no-unsafe-type-assertion" 4 "../../tests/unit/ReportActionsUtilsTest.ts" "@typescript-eslint/no-unsafe-type-assertion" 35 "../../tests/unit/ReportLayoutUtilsTest.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 -"../../tests/unit/ReportNameUtilsTest.ts" "@typescript-eslint/no-unsafe-type-assertion" 22 +"../../tests/unit/ReportNameUtilsTest.ts" "@typescript-eslint/no-unsafe-type-assertion" 23 "../../tests/unit/ReportPrimaryActionUtilsTest.ts" "@typescript-eslint/no-unsafe-type-assertion" 168 "../../tests/unit/ReportSecondaryActionUtilsTest.ts" "@typescript-eslint/no-unsafe-type-assertion" 450 "../../tests/unit/ReportSubmitUtilsTest.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 From 77d6d3f7b5363550ae55b2b59259ebbffd1df365 Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Wed, 10 Jun 2026 05:33:19 +0530 Subject: [PATCH 249/435] Fix row sentryLabel, selection cleanup, tax pending fields, accessibility, and formatDate safety Signed-off-by: krishna2323 --- src/CONST/index.ts | 1 + .../WorkspaceDistanceRatesTableRow.tsx | 11 +++++++++-- .../Tables/WorkspaceDistanceRatesTable/index.tsx | 2 ++ .../distanceRates/PolicyDistanceRatesPage.tsx | 15 +++++++++++++++ 4 files changed, 27 insertions(+), 2 deletions(-) diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 54ea4b5ff368..2e79e3016b97 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -9127,6 +9127,7 @@ const CONST = { BULK_ACTIONS_DROPDOWN: 'WorkspaceTaxes-BulkActionsDropdown', }, DISTANCE_RATES: { + ROW: 'WorkspaceDistanceRates-Row', ADD_BUTTON: 'WorkspaceDistanceRates-AddButton', MORE_DROPDOWN: 'WorkspaceDistanceRates-MoreDropdown', BULK_ACTIONS_DROPDOWN: 'WorkspaceDistanceRates-BulkActionsDropdown', diff --git a/src/components/Tables/WorkspaceDistanceRatesTable/WorkspaceDistanceRatesTableRow.tsx b/src/components/Tables/WorkspaceDistanceRatesTable/WorkspaceDistanceRatesTableRow.tsx index 6ee5166c2a39..72f997695ba7 100644 --- a/src/components/Tables/WorkspaceDistanceRatesTable/WorkspaceDistanceRatesTableRow.tsx +++ b/src/components/Tables/WorkspaceDistanceRatesTable/WorkspaceDistanceRatesTableRow.tsx @@ -44,7 +44,11 @@ function formatDate(dateString: string | null | undefined): string { if (!dateString) { return ''; } - return format(parseISO(dateString), CONST.DATE.MONTH_DAY_YEAR_ABBR_FORMAT); + try { + return format(parseISO(dateString), CONST.DATE.MONTH_DAY_YEAR_ABBR_FORMAT); + } catch { + return ''; + } } function useRateStatusColors(status: string): {backgroundColor: ColorValue; textColor: ColorValue} { @@ -77,6 +81,8 @@ function WorkspaceDistanceRatesTableRow({item, rowIndex, shouldUseNarrowTableLay const statusColors = useRateStatusColors(status); const dateLabelText = DistanceRequestUtils.getRateDateLabel({...rate, unit: CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES}, translate); + const accessibilityLabel = [rate.name, statusLabels[status], formattedRate, dateLabelText].filter(Boolean).join(', '); + const reasonAttributes: SkeletonSpanReasonAttributes = { context: 'WorkspaceDistanceRatesTableItem', isDeleting, @@ -87,8 +93,9 @@ function WorkspaceDistanceRatesTableRow({item, rowIndex, shouldUseNarrowTableLay interactive rowIndex={rowIndex} disabled={item.disabled} + accessibilityLabel={accessibilityLabel} skeletonReasonAttributes={reasonAttributes} - sentryLabel={CONST.SENTRY_LABEL.WORKSPACE.DISTANCE_RATES.ADD_BUTTON} + sentryLabel={CONST.SENTRY_LABEL.WORKSPACE.DISTANCE_RATES.ROW} offlineWithFeedback={{errors, pendingAction, dismissError: item.dismissError, shouldHideOnDelete: false}} onPress={item.action} > diff --git a/src/components/Tables/WorkspaceDistanceRatesTable/index.tsx b/src/components/Tables/WorkspaceDistanceRatesTable/index.tsx index a4b67d42f699..f0a5fbbb89db 100644 --- a/src/components/Tables/WorkspaceDistanceRatesTable/index.tsx +++ b/src/components/Tables/WorkspaceDistanceRatesTable/index.tsx @@ -83,6 +83,8 @@ function WorkspaceDistanceRatesTable({ rate.pendingFields?.rate ?? rate.pendingFields?.enabled ?? rate.pendingFields?.currency ?? + rate.pendingFields?.taxRateExternalID ?? + rate.pendingFields?.taxClaimablePercentage ?? rate.pendingFields?.name ?? pendingFields?.attributes ?? (pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD ? pendingAction : undefined); diff --git a/src/pages/workspace/distanceRates/PolicyDistanceRatesPage.tsx b/src/pages/workspace/distanceRates/PolicyDistanceRatesPage.tsx index 59a7843a1993..3a7da08422fd 100644 --- a/src/pages/workspace/distanceRates/PolicyDistanceRatesPage.tsx +++ b/src/pages/workspace/distanceRates/PolicyDistanceRatesPage.tsx @@ -11,6 +11,7 @@ import {ModalActions} from '@components/Modal/Global/ModalContext'; import ScreenWrapper from '@components/ScreenWrapper'; import WorkspaceDistanceRatesTable from '@components/Tables/WorkspaceDistanceRatesTable'; import Text from '@components/Text'; +import useCleanupSelectedOptions from '@hooks/useCleanupSelectedOptions'; import useConfirmModal from '@hooks/useConfirmModal'; import useFilteredSelection from '@hooks/useFilteredSelection'; import {useMemoizedLazyAsset, useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; @@ -139,6 +140,20 @@ function PolicyDistanceRatesPage({ const [selectedDistanceRates, setSelectedDistanceRates] = useFilteredSelection(selectableRates, filterRateSelection); + const clearTableSelection = useCallback(() => { + setSelectedDistanceRates((prev) => (prev.length > 0 ? [] : prev)); + }, [setSelectedDistanceRates]); + + useCleanupSelectedOptions(clearTableSelection); + + useEffect(() => { + if (isMobileSelectionModeEnabled) { + return; + } + + clearTableSelection(); + }, [clearTableSelection, isMobileSelectionModeEnabled]); + const canDisableOrDeleteSelectedRates = useMemo( () => Object.keys(selectableRates) From 23128f369d005614f3f4248f11738f8fbca03d1b Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Wed, 10 Jun 2026 05:36:41 +0530 Subject: [PATCH 250/435] Use local date instead of UTC for rate status comparison Signed-off-by: krishna2323 --- src/libs/PolicyDistanceRatesUtils.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/libs/PolicyDistanceRatesUtils.ts b/src/libs/PolicyDistanceRatesUtils.ts index 16c01dc69c7f..abab94dfa2eb 100644 --- a/src/libs/PolicyDistanceRatesUtils.ts +++ b/src/libs/PolicyDistanceRatesUtils.ts @@ -176,7 +176,8 @@ function getRateStatus(rate: Rate): string { return CONST.CUSTOM_UNITS.RATE_STATUS.INACTIVE; } - const now = new Date().toISOString().slice(0, 10); + const today = new Date(); + const now = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`; if (rate.startDate && rate.startDate > now) { return CONST.CUSTOM_UNITS.RATE_STATUS.FUTURE; From 9f3fef213ac1d34faa3e06f89457ec13295061d7 Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Wed, 10 Jun 2026 06:28:14 +0530 Subject: [PATCH 251/435] Increase status column width to 56px Signed-off-by: krishna2323 --- src/styles/variables.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/styles/variables.ts b/src/styles/variables.ts index 440983320d34..e1e56abb11ef 100644 --- a/src/styles/variables.ts +++ b/src/styles/variables.ts @@ -135,7 +135,7 @@ export default { tableGroupRowHeight: 36, tableSkeletonHeight: 32, tableCheckboxColumnWidth: 20, - tableStatusColumnWidth: 48, + tableStatusColumnWidth: 56, tableSwitchColumnWidth: 58, tableCaretColumnWidth: 20, domainTableActionColumnWidth: 64, From 0b700da0c88e219424cceead6d222a2310860b18 Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Wed, 10 Jun 2026 06:30:01 +0530 Subject: [PATCH 252/435] Fix narrow layout ref not resetting when page opens in narrow mode Signed-off-by: krishna2323 --- src/components/Tables/WorkspaceDistanceRatesTable/index.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/components/Tables/WorkspaceDistanceRatesTable/index.tsx b/src/components/Tables/WorkspaceDistanceRatesTable/index.tsx index f0a5fbbb89db..45308562284f 100644 --- a/src/components/Tables/WorkspaceDistanceRatesTable/index.tsx +++ b/src/components/Tables/WorkspaceDistanceRatesTable/index.tsx @@ -191,12 +191,14 @@ function WorkspaceDistanceRatesTable({ return; } - if (!activeSortingInWideLayout || !isNarrowLayoutRef.current) { + if (!isNarrowLayoutRef.current) { return; } isNarrowLayoutRef.current = false; - tableRef.current?.updateSorting(activeSortingInWideLayout); + if (activeSortingInWideLayout) { + tableRef.current?.updateSorting(activeSortingInWideLayout); + } }, [activeSortingInWideLayout, shouldUseNarrowTableLayout]); const shouldShowSearchBar = Object.keys(customUnitRates).length >= CONST.STANDARD_LIST_ITEM_LIMIT; From 3fa797af53aa798dbeb29450c4e053e859dcaaba Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Wed, 10 Jun 2026 06:41:41 +0530 Subject: [PATCH 253/435] Keep table mounted when empty and revert tableRowHeightCompact to 72 Signed-off-by: krishna2323 --- .../Tables/WorkspaceDistanceRatesTable/index.tsx | 14 +++++++++++--- .../distanceRates/PolicyDistanceRatesPage.tsx | 10 ++++++---- src/styles/variables.ts | 2 +- 3 files changed, 18 insertions(+), 8 deletions(-) diff --git a/src/components/Tables/WorkspaceDistanceRatesTable/index.tsx b/src/components/Tables/WorkspaceDistanceRatesTable/index.tsx index 45308562284f..2bb5e3533e1c 100644 --- a/src/components/Tables/WorkspaceDistanceRatesTable/index.tsx +++ b/src/components/Tables/WorkspaceDistanceRatesTable/index.tsx @@ -30,6 +30,7 @@ type WorkspaceDistanceRatesTableProps = { canDisableOrDeleteRate: (rateID: string) => boolean; pendingAction?: OnyxCommon.PendingAction; pendingFields?: OnyxCommon.PendingFields; + EmptyStateComponent?: React.ReactElement; }; const STATUS_ORDER: Record = { @@ -51,6 +52,7 @@ function WorkspaceDistanceRatesTable({ canDisableOrDeleteRate, pendingAction, pendingFields, + EmptyStateComponent, }: WorkspaceDistanceRatesTableProps) { const styles = useThemeStyles(); const {translate, localeCompare} = useLocalize(); @@ -201,6 +203,7 @@ function WorkspaceDistanceRatesTable({ } }, [activeSortingInWideLayout, shouldUseNarrowTableLayout]); + const isEmpty = ratesData.length === 0; const shouldShowSearchBar = Object.keys(customUnitRates).length >= CONST.STANDARD_LIST_ITEM_LIMIT; return ( @@ -218,9 +221,14 @@ function WorkspaceDistanceRatesTable({ initialSortColumn="name" title={translate('workspace.common.distanceRates')} > - {shouldShowSearchBar && } - - + {isEmpty && EmptyStateComponent} + {!isEmpty && ( + <> + {shouldShowSearchBar && } + + + + )} ); } diff --git a/src/pages/workspace/distanceRates/PolicyDistanceRatesPage.tsx b/src/pages/workspace/distanceRates/PolicyDistanceRatesPage.tsx index 3a7da08422fd..5868b85a9026 100644 --- a/src/pages/workspace/distanceRates/PolicyDistanceRatesPage.tsx +++ b/src/pages/workspace/distanceRates/PolicyDistanceRatesPage.tsx @@ -436,11 +436,13 @@ function PolicyDistanceRatesPage({ reasonAttributes={reasonAttributes} /> )} - {Object.values(customUnitRates).length > 0 && ( + {!isLoading && ( <> - - {translate('workspace.distanceRates.centrallyManage')} - + {Object.values(customUnitRates).length > 0 && ( + + {translate('workspace.distanceRates.centrallyManage')} + + )} Date: Wed, 10 Jun 2026 03:56:28 +0200 Subject: [PATCH 254/435] talkback focus --- src/components/DatePicker/index.tsx | 2 +- src/hooks/useAutoFocusInput.ts | 11 ++++++++--- .../Accessibility/moveAccessibilityFocus/index.ts | 2 +- .../Accessibility/moveAccessibilityFocus/types.ts | 4 ++-- 4 files changed, 12 insertions(+), 7 deletions(-) diff --git a/src/components/DatePicker/index.tsx b/src/components/DatePicker/index.tsx index 01f0d72a8b71..a54830c016f3 100644 --- a/src/components/DatePicker/index.tsx +++ b/src/components/DatePicker/index.tsx @@ -56,7 +56,7 @@ function DatePicker({ // picker was dismissed before it resolved. const openIntentRef = useRef(false); - const {inputCallbackRef: autoFocusCallbackRef, cancelAutoFocus} = useAutoFocusInput(); + const {inputCallbackRef: autoFocusCallbackRef, cancelAutoFocus} = useAutoFocusInput(false, true); const autoFocusCallbackRefRef = useRef(autoFocusCallbackRef); autoFocusCallbackRefRef.current = autoFocusCallbackRef; diff --git a/src/hooks/useAutoFocusInput.ts b/src/hooks/useAutoFocusInput.ts index ea29b398cc03..956de0bb7673 100644 --- a/src/hooks/useAutoFocusInput.ts +++ b/src/hooks/useAutoFocusInput.ts @@ -25,7 +25,7 @@ type UseAutoFocusInput = { cancelAutoFocus: () => void; }; -export default function useAutoFocusInput(isMultiline = false): UseAutoFocusInput { +export default function useAutoFocusInput(isMultiline = false, shouldMoveAccessibilityFocus = false): UseAutoFocusInput { const [isInputInitialized, setIsInputInitialized] = useState(false); const [isScreenTransitionEnded, setIsScreenTransitionEnded] = useState(false); const [modal] = useOnyx(ONYXKEYS.MODAL); @@ -56,7 +56,7 @@ export default function useAutoFocusInput(isMultiline = false): UseAutoFocusInpu useEffect(() => { if ( - isScreenReaderEnabled || + (isScreenReaderEnabled && !shouldMoveAccessibilityFocus) || !isScreenTransitionEnded || !isInputInitialized || !inputRef.current || @@ -67,6 +67,11 @@ export default function useAutoFocusInput(isMultiline = false): UseAutoFocusInpu return; } const focusTaskHandle = InteractionManager.runAfterInteractions(() => { + if (isScreenReaderEnabled) { + Accessibility.moveAccessibilityFocus(inputRef.current); + setIsScreenTransitionEnded(false); + return; + } if (inputRef.current && isMultiline) { moveSelectionToEnd(inputRef.current); } @@ -95,7 +100,7 @@ export default function useAutoFocusInput(isMultiline = false): UseAutoFocusInpu return () => { focusTaskHandle.cancel(); }; - }, [isScreenReaderEnabled, isMultiline, isScreenTransitionEnded, isInputInitialized, splashScreenState, isPopoverVisible, isInLandscapeMode]); + }, [isScreenReaderEnabled, shouldMoveAccessibilityFocus, isMultiline, isScreenTransitionEnded, isInputInitialized, splashScreenState, isPopoverVisible, isInLandscapeMode]); useFocusEffect( useCallback(() => { diff --git a/src/libs/Accessibility/moveAccessibilityFocus/index.ts b/src/libs/Accessibility/moveAccessibilityFocus/index.ts index cafe1a216db3..c73e52bda726 100644 --- a/src/libs/Accessibility/moveAccessibilityFocus/index.ts +++ b/src/libs/Accessibility/moveAccessibilityFocus/index.ts @@ -1,7 +1,7 @@ import type MoveAccessibilityFocus from './types'; const moveAccessibilityFocus: MoveAccessibilityFocus = (ref) => { - if (!ref?.current) { + if (!ref || !('current' in ref) || !ref.current) { return; } ref.current.focus(); diff --git a/src/libs/Accessibility/moveAccessibilityFocus/types.ts b/src/libs/Accessibility/moveAccessibilityFocus/types.ts index 6756bdd6f773..d6ef3207306a 100644 --- a/src/libs/Accessibility/moveAccessibilityFocus/types.ts +++ b/src/libs/Accessibility/moveAccessibilityFocus/types.ts @@ -1,6 +1,6 @@ import type {ElementRef, RefObject} from 'react'; -import type {HostComponent} from 'react-native'; +import type {HostComponent, TextInput} from 'react-native'; -type MoveAccessibilityFocus = (ref?: ElementRef> & RefObject) => void; +type MoveAccessibilityFocus = (ref?: (ElementRef> & RefObject) | TextInput | null) => void; export default MoveAccessibilityFocus; From 9d9c8fcea0c2d0be8b84bfd290d628bbd429840a Mon Sep 17 00:00:00 2001 From: jayeshmangwani Date: Wed, 10 Jun 2026 11:56:00 +0530 Subject: [PATCH 255/435] added shouldShowMarkAsDone check to DeferredActionCell and FloatingMessageCounter --- .../MoneyRequestReportActionsList.tsx | 11 +++++++++- .../ActionCell/DeferredActionCell.tsx | 22 ++++++++++++++++++- .../inbox/report/FloatingMessageCounter.tsx | 18 +++++++++++++-- src/pages/inbox/report/ReportActionsList.tsx | 13 +++++++++++ 4 files changed, 60 insertions(+), 4 deletions(-) diff --git a/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx b/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx index 333261612976..bc19b0285efa 100644 --- a/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx +++ b/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx @@ -1,5 +1,6 @@ /* eslint-disable rulesdir/prefer-early-return */ import {useIsFocused, useRoute} from '@react-navigation/native'; +import {isTrackIntentUserSelector} from '@selectors/Onboarding'; import isEmpty from 'lodash/isEmpty'; import React, {useCallback, useContext, useEffect, useLayoutEffect, useMemo, useRef, useState} from 'react'; import type {LayoutChangeEvent, ListRenderItemInfo, NativeScrollEvent, NativeSyntheticEvent} from 'react-native'; @@ -42,7 +43,7 @@ import { isReportActionVisible, wasMessageReceivedWhileOffline, } from '@libs/ReportActionsUtils'; -import {canUserPerformWriteAction, chatIncludesChronosWithID, getReportLastVisibleActionCreated, isHarvestCreatedExpenseReport, isUnread} from '@libs/ReportUtils'; +import {canUserPerformWriteAction, chatIncludesChronosWithID, getReportLastVisibleActionCreated, isHarvestCreatedExpenseReport, isUnread, shouldShowMarkAsDone} from '@libs/ReportUtils'; import markOpenReportEnd from '@libs/telemetry/markOpenReportEnd'; import type {SkeletonSpanReasonAttributes} from '@libs/telemetry/useSkeletonSpan'; import Visibility from '@libs/Visibility'; @@ -656,6 +657,13 @@ function MoneyRequestReportActionsList({onLayout}: MoneyRequestReportListProps) if (!report) { return null; } + const [isTrackIntentUser] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED, {selector: isTrackIntentUserSelector}); + + const shouldUseMarkAsDoneCopy = shouldShowMarkAsDone({ + policy, + report, + isTrackIntentUser, + }); return ( {/* Exactly one of these two branches is active at a time: 1. showEmptyState — genuinely empty report diff --git a/src/components/Search/SearchList/ListItem/ActionCell/DeferredActionCell.tsx b/src/components/Search/SearchList/ListItem/ActionCell/DeferredActionCell.tsx index a8687c6f60ae..85eca228b672 100644 --- a/src/components/Search/SearchList/ListItem/ActionCell/DeferredActionCell.tsx +++ b/src/components/Search/SearchList/ListItem/ActionCell/DeferredActionCell.tsx @@ -1,8 +1,13 @@ +import {isTrackIntentUserSelector} from '@selectors/Onboarding'; import React, {useDeferredValue} from 'react'; import Button from '@components/Button'; import useLocalize from '@hooks/useLocalize'; +import useOnyx from '@hooks/useOnyx'; import useThemeStyles from '@hooks/useThemeStyles'; +import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; +import {shouldShowMarkAsDone} from '@libs/ReportUtils'; import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; import ActionCell from '.'; import type {ActionCellProps} from '.'; import actionTranslationsMap from './actionTranslationsMap'; @@ -11,12 +16,27 @@ function DeferredActionCell(actionCellProps: ActionCellProps) { const styles = useThemeStyles(); const {translate} = useLocalize(); const shouldRender = useDeferredValue(true, false); + const [isTrackIntentUser] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED, {selector: isTrackIntentUserSelector}); + const [actionCellPolicy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${getNonEmptyStringOnyxID(actionCellProps.policyID)}`); + const [actionCellReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${getNonEmptyStringOnyxID(actionCellProps.reportID)}`); if (!shouldRender) { const action = actionCellProps.action ?? CONST.SEARCH.ACTION_TYPES.VIEW; const shouldUseViewAction = action === CONST.SEARCH.ACTION_TYPES.VIEW || action === CONST.SEARCH.ACTION_TYPES.PAID || action === CONST.SEARCH.ACTION_TYPES.DONE; const isSuccess = !shouldUseViewAction && action !== CONST.SEARCH.ACTION_TYPES.UNDELETE; - const text = shouldUseViewAction ? translate(actionTranslationsMap[CONST.SEARCH.ACTION_TYPES.VIEW]) : translate(actionTranslationsMap[action]); + let text: string; + if (shouldUseViewAction) { + text = translate(actionTranslationsMap[CONST.SEARCH.ACTION_TYPES.VIEW]); + } else { + const shouldUseMarkAsDone = + shouldShowMarkAsDone({ + isTrackIntentUser, + report: actionCellReport, + policy: actionCellPolicy, + }) && action === CONST.SEARCH.ACTION_TYPES.SUBMIT; + + text = shouldUseMarkAsDone ? translate('common.done') : translate(actionTranslationsMap[action]); + } return (