diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index b4dbce6adc138..8ecdf1fafd8f8 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -729,6 +729,7 @@ type OptimisticTaskReport = SetRequired< Pick< Report, | 'reportID' + | 'created' | 'reportName' | 'description' | 'ownerAccountID' @@ -8559,6 +8560,7 @@ function buildOptimisticTaskReport( return { reportID: generateReportID(), + created: DateUtils.getDBTime(), reportName: getParsedComment(title ?? '', undefined, undefined, [...CONST.TASK_TITLE_DISABLED_RULES]), description: getParsedComment(description ?? '', {}), ownerAccountID, diff --git a/src/libs/SearchQueryUtils.ts b/src/libs/SearchQueryUtils.ts index f6762c0cabfa5..d748b07116e92 100644 --- a/src/libs/SearchQueryUtils.ts +++ b/src/libs/SearchQueryUtils.ts @@ -1,5 +1,6 @@ import cloneDeep from 'lodash/cloneDeep'; -import type {OnyxCollection} from 'react-native-onyx'; +import Onyx from 'react-native-onyx'; +import type {OnyxCollection, OnyxUpdate} from 'react-native-onyx'; import type {ValueOf} from 'type-fest'; import type {LocaleContextProps, LocalizedTranslate} from '@components/LocaleContextProvider'; import type { @@ -1645,6 +1646,25 @@ function shouldSkipSuggestedSearchNavigation(queryJSON?: SearchQueryJSON) { return !!queryJSON.rawFilterList || hasKeywordFilter || hasContextFilter || hasInlineKeywordFilter || hasInlineContextFilter || isChatSearch; } +/** + * Builds an optimistic Snapshot update to ensure offline data for Tasks and Chat messages appears in Search. + */ +function buildOptimisticSnapshotData(type: SearchDataTypes, data: Record): OnyxUpdate | undefined { + const searchQuery = buildCannedSearchQuery({type}); + const searchQueryJSON = buildSearchQueryJSON(searchQuery); + if (!searchQueryJSON) { + return; + } + return { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.SNAPSHOT}${searchQueryJSON.hash}`, + value: { + // @ts-expect-error - will be solved in https://github.com/Expensify/App/issues/73830 + data, + }, + }; +} + export { isSearchDatePreset, isFilterSupported, @@ -1669,4 +1689,5 @@ export { getUserFriendlyValue, getUserFriendlyKey, shouldSkipSuggestedSearchNavigation, + buildOptimisticSnapshotData, }; diff --git a/src/libs/actions/Report/index.ts b/src/libs/actions/Report/index.ts index ca315fcabdf48..036429eebb64c 100644 --- a/src/libs/actions/Report/index.ts +++ b/src/libs/actions/Report/index.ts @@ -160,7 +160,7 @@ import { isValidReportIDFromPath, prepareOnboardingOnyxData, } from '@libs/ReportUtils'; -import {getCurrentSearchQueryJSON} from '@libs/SearchQueryUtils'; +import {buildOptimisticSnapshotData, getCurrentSearchQueryJSON} from '@libs/SearchQueryUtils'; import playSound, {SOUNDS} from '@libs/Sound'; import {getAmount, getCurrency, hasValidModifiedAmount, isOnHold, shouldClearConvertedAmount} from '@libs/TransactionUtils'; import addTrailingForwardSlash from '@libs/UrlUtils'; @@ -725,7 +725,9 @@ function addActions({ parameters.pregeneratedResponse = pregeneratedResponseParams.pregeneratedResponse; } - const optimisticData: Array> = [ + const optimisticData: Array< + OnyxUpdate + > = [ { onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.REPORT}${reportID}`, @@ -738,6 +740,17 @@ function addActions({ }, ]; + const snapshotDataToStore = { + [`${ONYXKEYS.COLLECTION.REPORT}${reportID}`]: optimisticReport, + [`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`]: optimisticReportActions, + }; + const optimisticSnapshotUpdate = buildOptimisticSnapshotData(CONST.SEARCH.DATA_TYPES.CHAT, snapshotDataToStore); + + // We are pushing the optimistic report and report actions into the chat snapshot so that the newly sent message appears immediately in "Reports > Chats" while offline. + if (optimisticSnapshotUpdate) { + optimisticData.push(optimisticSnapshotUpdate); + } + optimisticData.push(...getOptimisticDataForAncestors(ancestors, currentTime, CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD)); const successReportActions: OnyxCollection> = {}; @@ -787,7 +800,7 @@ function addActions({ failureReportActions[pregeneratedResponseParams.optimisticConciergeReportActionID] = null; } - const failureData: Array> = [ + const failureData: Array> = [ { onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.REPORT}${reportID}`, diff --git a/src/libs/actions/ReportActions.ts b/src/libs/actions/ReportActions.ts index 39529ea608844..acc723e47e79e 100644 --- a/src/libs/actions/ReportActions.ts +++ b/src/libs/actions/ReportActions.ts @@ -2,6 +2,7 @@ import type {OnyxCollection} from 'react-native-onyx'; import Onyx from 'react-native-onyx'; import {getLinkedTransactionID, getReportAction, getReportActionMessage, isCreatedTaskReportAction} from '@libs/ReportActionsUtils'; import {getOriginalReportID} from '@libs/ReportUtils'; +import {buildOptimisticSnapshotData} from '@libs/SearchQueryUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type * as OnyxTypes from '@src/types/onyx'; @@ -49,6 +50,27 @@ function clearReportActionErrors(reportID: string, reportAction: ReportAction, o if (taskReportID && isCreatedTaskReportAction(reportAction)) { deleteReport(taskReportID); } + + // Clear the chat snapshot entry for the failed optimistic action so it disappears from Reports > Chats. + const snapshotDataToClear: Record = { + [`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${originalReportID}`]: { + [reportAction.reportActionID]: null, + }, + }; + if (taskReportID && isCreatedTaskReportAction(reportAction)) { + // If this is a failed optimistic task-create action, also remove the task report snapshot data so it disappears from Reports > Task when the user dismiss the error. + snapshotDataToClear[`${ONYXKEYS.COLLECTION.REPORT}${taskReportID}`] = null; + snapshotDataToClear[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${taskReportID}`] = null; + } + + // Apply the same cleanup to snapshot hashes used by Reports > Chats and Reports > Task. + for (const type of [CONST.SEARCH.DATA_TYPES.CHAT, CONST.SEARCH.DATA_TYPES.TASK]) { + const snapshotUpdate = buildOptimisticSnapshotData(type, snapshotDataToClear); + if (!snapshotUpdate) { + continue; + } + Onyx.merge(snapshotUpdate.key, snapshotUpdate.value as OnyxTypes.SearchResults); + } return; } diff --git a/src/libs/actions/Task.ts b/src/libs/actions/Task.ts index e00ba5ea1f241..7ed7ed7f9d6dd 100644 --- a/src/libs/actions/Task.ts +++ b/src/libs/actions/Task.ts @@ -15,6 +15,7 @@ import * as OptionsListUtils from '@libs/OptionsListUtils'; import * as PersonalDetailsUtils from '@libs/PersonalDetailsUtils'; import * as ReportActionsUtils from '@libs/ReportActionsUtils'; import * as ReportUtils from '@libs/ReportUtils'; +import {buildOptimisticSnapshotData} from '@libs/SearchQueryUtils'; import playSound, {SOUNDS} from '@libs/Sound'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -125,26 +126,32 @@ function createTaskAndNavigate(params: CreateTaskAndNavigateParams) { hasOutstandingChildTask: assigneeAccountID === currentUserAccountID ? true : parentReport?.hasOutstandingChildTask, }; + const completeOptimisticTaskReport = { + ...optimisticTaskReport, + pendingFields: { + createChat: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, + reportName: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, + description: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, + managerID: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, + }, + }; + // We're only setting onyx data for the task report here because it's possible for the parent report to not exist yet (if you're assigning a task to someone you haven't chatted with before) // So we don't want to set the parent report data until we've successfully created that chat report // FOR TASK REPORT const optimisticData: Array< OnyxUpdate< - typeof ONYXKEYS.COLLECTION.REPORT | typeof ONYXKEYS.COLLECTION.REPORT_METADATA | typeof ONYXKEYS.COLLECTION.REPORT_ACTIONS | typeof ONYXKEYS.NVP_QUICK_ACTION_GLOBAL_CREATE + | typeof ONYXKEYS.COLLECTION.REPORT + | typeof ONYXKEYS.COLLECTION.REPORT_METADATA + | typeof ONYXKEYS.COLLECTION.REPORT_ACTIONS + | typeof ONYXKEYS.NVP_QUICK_ACTION_GLOBAL_CREATE + | typeof ONYXKEYS.COLLECTION.SNAPSHOT > > = [ { onyxMethod: Onyx.METHOD.SET, key: `${ONYXKEYS.COLLECTION.REPORT}${optimisticTaskReport.reportID}`, - value: { - ...optimisticTaskReport, - pendingFields: { - createChat: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, - reportName: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, - description: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, - managerID: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, - }, - }, + value: completeOptimisticTaskReport, }, { onyxMethod: Onyx.METHOD.MERGE, @@ -160,6 +167,13 @@ function createTaskAndNavigate(params: CreateTaskAndNavigateParams) { }, ]; + // We need the personal details of the task creator and assignee so that the "From" and "Assignee" columns wil be rendered in "Reports > Task" while offline. + const personalDetailsList = PersonalDetailsUtils.createPersonalDetailsLookupByAccountID( + PersonalDetailsUtils.getPersonalDetailsByIDs({ + accountIDs: [currentUserAccountID, assigneeAccountID], + }), + ); + // FOR TASK REPORT const successData: Array< OnyxUpdate @@ -192,21 +206,18 @@ function createTaskAndNavigate(params: CreateTaskAndNavigateParams) { }, ]; - // FOR TASK REPORT + // We intentionally aren't deleting the optimistic + // task report/action on API failure so the task stays visible until the user dismiss the + // error from chat. const failureData: Array< - OnyxUpdate - > = [ - { - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT}${optimisticTaskReport.reportID}`, - value: null, - }, - { - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${optimisticTaskReport.reportID}`, - value: null, - }, - ]; + OnyxUpdate< + | typeof ONYXKEYS.COLLECTION.REPORT + | typeof ONYXKEYS.COLLECTION.REPORT_ACTIONS + | typeof ONYXKEYS.PERSONAL_DETAILS_LIST + | typeof ONYXKEYS.NVP_QUICK_ACTION_GLOBAL_CREATE + | typeof ONYXKEYS.COLLECTION.SNAPSHOT + > + > = []; if (assigneeChatReport && assigneeChatReportID) { assigneeChatReportOnyxData = ReportUtils.getTaskAssigneeChatOnyxData( @@ -239,6 +250,31 @@ function createTaskAndNavigate(params: CreateTaskAndNavigateParams) { }, ); + const searchDataTypes = [CONST.SEARCH.DATA_TYPES.CHAT, CONST.SEARCH.DATA_TYPES.TASK]; + + const snapshotDataToStore = { + [`${ONYXKEYS.COLLECTION.REPORT}${optimisticTaskReport.reportID}`]: { + ...completeOptimisticTaskReport, + accountID: currentUserAccountID, + }, + [`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${optimisticTaskReport.reportID}`]: { + [optimisticTaskCreatedAction.reportActionID]: optimisticTaskCreatedAction as OnyxTypes.ReportAction, + }, + [`${ONYXKEYS.COLLECTION.REPORT}${parentReportID}`]: optimisticParentReport, + [`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${parentReportID}`]: { + [optimisticAddCommentReport.reportAction.reportActionID]: optimisticAddCommentReport.reportAction as OnyxTypes.ReportAction, + }, + [ONYXKEYS.PERSONAL_DETAILS_LIST]: personalDetailsList, + }; + + // We push the optimistic task data into chat and task snapshot hashes so it appears immediately in "Reports > Chats" and "Reports > Task" while offline. + for (const type of searchDataTypes) { + const optimisticSnapshotUpdate = buildOptimisticSnapshotData(type, snapshotDataToStore); + if (optimisticSnapshotUpdate) { + optimisticData.push(optimisticSnapshotUpdate); + } + } + const shouldUpdateNotificationPreference = !isEmptyObject(parentReport) && ReportUtils.isHiddenForCurrentUser(parentReport); if (shouldUpdateNotificationPreference) { optimisticData.push({ diff --git a/tests/actions/ReportTest.ts b/tests/actions/ReportTest.ts index 3a6edbf75ecde..d6f78b5990374 100644 --- a/tests/actions/ReportTest.ts +++ b/tests/actions/ReportTest.ts @@ -27,6 +27,7 @@ import DateUtils from '@src/libs/DateUtils'; import Log from '@src/libs/Log'; import * as SequentialQueue from '@src/libs/Network/SequentialQueue'; import * as ReportUtils from '@src/libs/ReportUtils'; +import type * as SearchQueryUtilsType from '@src/libs/SearchQueryUtils'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import type * as OnyxTypes from '@src/types/onyx'; @@ -73,15 +74,19 @@ jest.mock('@libs/ReportUtils', () => { /* eslint-enable @typescript-eslint/no-unsafe-assignment */ const currentHash = 12345; -jest.mock('@src/libs/SearchQueryUtils', () => ({ - getCurrentSearchQueryJSON: jest.fn().mockImplementation(() => ({ - hash: currentHash, - query: 'test', - type: 'expense', - status: '', - flatFilters: [], - })), -})); +jest.mock('@src/libs/SearchQueryUtils', () => { + const originalSearchQueryModule = jest.requireActual('@src/libs/SearchQueryUtils'); + return { + ...originalSearchQueryModule, + getCurrentSearchQueryJSON: jest.fn().mockImplementation(() => ({ + hash: currentHash, + query: 'test', + type: 'expense', + status: '', + flatFilters: [], + })), + }; +}); const UTC = 'UTC'; jest.mock('@src/libs/actions/Report', () => {