diff --git a/src/libs/ReportActionsFollowupUtils.ts b/src/libs/ReportActionsFollowupUtils.ts new file mode 100644 index 0000000000000..e068a3935daea --- /dev/null +++ b/src/libs/ReportActionsFollowupUtils.ts @@ -0,0 +1,57 @@ +import CONST from '@src/CONST'; +import type {OnyxInputOrEntry, ReportAction} from '@src/types/onyx'; +import type {Followup} from './ReportActionsUtils'; +import {getReportActionMessage, isActionOfType} from './ReportActionsUtils'; + +/** + * Checks if a report action contains actionable (unresolved) followup suggestions. + * @param reportAction - The report action to check + * @returns true if the action is an ADD_COMMENT with unresolved followups, false otherwise + */ +function containsActionableFollowUps(reportAction: OnyxInputOrEntry): boolean { + const isActionAComment = isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT); + if (!isActionAComment) { + return false; + } + const messageHtml = getReportActionMessage(reportAction)?.html; + if (!messageHtml) { + return false; + } + const followups = parseFollowupsFromHtml(messageHtml); + + return !!followups && followups.length > 0; +} + +// Matches a HTML element and its entire contents. (Question?) +const followUpListRegex = /]*)?>[\s\S]*?<\/followup-list>/i; +/** + * Parses followup data from a HTML element. + * @param html - The HTML string to parse for elements + * @returns null if no exists, empty array [] if the followup-list has the 'selected' attribute (resolved state), or an array of followup objects if unresolved + */ +function parseFollowupsFromHtml(html: string): Followup[] | null { + const followupListMatch = html.match(followUpListRegex); + if (!followupListMatch) { + return null; + } + + // There will be only one follow up list + const followupListHtml = followupListMatch[0]; + // Matches a element that has the "selected" attribute (...). + const followUpSelectedListRegex = /]*\sselected[\s>]/i; + const hasSelectedAttribute = followUpSelectedListRegex.test(followupListHtml); + if (hasSelectedAttribute) { + return []; + } + + const followups: Followup[] = []; + // Matches individual ... elements + const followUpTextRegex = /([^<]*)<\/followup-text><\/followup>/gi; + let match = followUpTextRegex.exec(followupListHtml); + while (match !== null) { + followups.push({text: match[1]}); + match = followUpTextRegex.exec(followupListHtml); + } + return followups; +} +export {containsActionableFollowUps, parseFollowupsFromHtml}; diff --git a/src/libs/ReportActionsUtils.ts b/src/libs/ReportActionsUtils.ts index fb89ac5bcab46..f6814a8a7c9aa 100644 --- a/src/libs/ReportActionsUtils.ts +++ b/src/libs/ReportActionsUtils.ts @@ -64,6 +64,10 @@ type MemberChangeMessageRoomReferenceElement = { type MemberChangeMessageElement = MessageTextElement | MemberChangeMessageUserMentionElement | MemberChangeMessageRoomReferenceElement; +type Followup = { + text: string; +}; + function isPolicyExpenseChat(report: OnyxInputOrEntry): boolean { return report?.chatType === CONST.REPORT.CHAT_TYPE.POLICY_EXPENSE_CHAT || !!(report && typeof report === 'object' && 'isPolicyExpenseChat' in report && report.isPolicyExpenseChat); } @@ -1729,6 +1733,21 @@ function getMemberChangeMessageElements( ]; } +/** + * Used for generating preview text in LHN and other places where followups should not be displayed. + * Implemented here instead of ReportActionFollowupUtils due to circular ref + * @param html message.html from the report COMMENT actions + * @returns html with the element and its contents stripped out or undefined if html is undefined + */ +function stripFollowupListFromHtml(html?: string): string | undefined { + if (!html) { + return; + } + // Matches a HTML element and its entire contents. (Question?) + const followUpListRegex = /]*)?>[\s\S]*?<\/followup-list>/i; + return html.replace(followUpListRegex, '').trim(); +} + function getReportActionHtml(reportAction: PartialReportAction): string { return getReportActionMessage(reportAction)?.html ?? ''; } @@ -1737,7 +1756,7 @@ function getReportActionText(reportAction: PartialReportAction): string { const message = getReportActionMessage(reportAction); // Sometime html can be an empty string // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing - const text = (message?.html || message?.text) ?? ''; + const text = stripFollowupListFromHtml(message?.html) || (message?.text ?? ''); return text ? Parser.htmlToText(text) : ''; } @@ -3972,6 +3991,7 @@ export { withDEWRoutedActionsArray, withDEWRoutedActionsObject, getReportActionActorAccountID, + stripFollowupListFromHtml, }; -export type {LastVisibleMessage}; +export type {LastVisibleMessage, Followup}; diff --git a/src/libs/actions/Report.ts b/src/libs/actions/Report.ts index f9aef5e51f55c..fb359ebd85c73 100644 --- a/src/libs/actions/Report.ts +++ b/src/libs/actions/Report.ts @@ -102,7 +102,9 @@ import { import processReportIDDeeplink from '@libs/processReportIDDeeplink'; import Pusher from '@libs/Pusher'; import type {UserIsLeavingRoomEvent, UserIsTypingEvent} from '@libs/Pusher/types'; +import * as ReportActionsFollowupUtils from '@libs/ReportActionsFollowupUtils'; import * as ReportActionsUtils from '@libs/ReportActionsUtils'; +import {getLastVisibleAction} from '@libs/ReportActionsUtils'; import {updateTitleFieldToMatchPolicy} from '@libs/ReportTitleUtils'; import type {Ancestor, OptimisticAddCommentReportAction, OptimisticChatReport, SelfDMParameters} from '@libs/ReportUtils'; import { @@ -532,6 +534,39 @@ function notifyNewAction(reportID: string | undefined, accountID: number | undef actionSubscriber.callback(isFromCurrentUser, reportAction); } +/** + * Builds an optimistic report action with resolved followups (followup-list marked as selected). + * @param reportAction - The report action to check and potentially resolve + * @returns Null if the action doesn't have unresolved followups or the updated report action with resolved followups. + */ +function buildOptimisticResolvedFollowups(reportAction: OnyxEntry): ReportAction | null { + if (!reportAction) { + return null; + } + + const message = ReportActionsUtils.getReportActionMessage(reportAction); + if (!message) { + return null; + } + const html = message?.html ?? ''; + const followups = ReportActionsFollowupUtils.parseFollowupsFromHtml(html); + + if (!followups || followups.length === 0) { + return null; + } + + const updatedHtml = html.replace(/]*)?>/, ''); + return { + ...reportAction, + message: [ + { + ...message, + html: updatedHtml, + }, + ], + }; +} + /** * Add up to two report actions to a report. This method can be called for the following situations: * @@ -597,7 +632,7 @@ function addActions(report: OnyxEntry, notifyReportID: string, ancestors } // Optimistically add the new actions to the store before waiting to save them to the server - const optimisticReportActions: OnyxCollection = {}; + const optimisticReportActions: OnyxCollection = {}; // Only add the reportCommentAction when there is no file attachment. If there is both a file attachment and text, that will all be contained in the attachmentAction. if (text && reportCommentAction?.reportActionID && !file) { @@ -606,6 +641,17 @@ function addActions(report: OnyxEntry, notifyReportID: string, ancestors if (file && attachmentAction?.reportActionID) { optimisticReportActions[attachmentAction.reportActionID] = attachmentAction; } + + // Check if the last visible action is from Concierge with unresolved followups + // If so, optimistically resolve them by adding the updated action to optimisticReportActions + const lastVisibleAction = getLastVisibleAction(reportID); + const lastActorAccountID = lastVisibleAction?.actorAccountID; + const lastActionReportActionID = lastVisibleAction?.reportActionID; + const resolvedAction = buildOptimisticResolvedFollowups(lastVisibleAction); + if (lastActorAccountID === CONST.ACCOUNT_ID.CONCIERGE && lastActionReportActionID && resolvedAction) { + optimisticReportActions[lastActionReportActionID] = resolvedAction; + } + const parameters: AddCommentOrAttachmentParams = { reportID, reportActionID: file ? attachmentAction?.reportActionID : reportCommentAction?.reportActionID, @@ -662,9 +708,7 @@ function addActions(report: OnyxEntry, notifyReportID: string, ancestors }; const {lastMessageText = ''} = ReportActionsUtils.getLastVisibleMessage(reportID); if (lastMessageText) { - const lastVisibleAction = ReportActionsUtils.getLastVisibleAction(reportID); const lastVisibleActionCreated = lastVisibleAction?.created; - const lastActorAccountID = lastVisibleAction?.actorAccountID; failureReport = { lastMessageText, lastVisibleActionCreated, @@ -672,7 +716,7 @@ function addActions(report: OnyxEntry, notifyReportID: string, ancestors }; } - const failureReportActions: Record = {}; + const failureReportActions: Record = {}; for (const [actionKey, action] of Object.entries(optimisticReportActions)) { failureReportActions[actionKey] = { @@ -681,6 +725,10 @@ function addActions(report: OnyxEntry, notifyReportID: string, ancestors errors: getMicroSecondOnyxErrorWithTranslationKey('report.genericAddCommentFailureMessage'), }; } + // In case of error bring back the follow up buttons to the cast comment + if (lastActorAccountID === CONST.ACCOUNT_ID.CONCIERGE && lastActionReportActionID) { + failureReportActions[lastActionReportActionID] = lastVisibleAction; + } const failureData: Array> = [ { @@ -6495,6 +6543,46 @@ function resolveConciergeDescriptionOptions( resolveConciergeOptions(report, notifyReportID, reportActionID, selectedDescription, timezoneParam, 'selectedDescription', ancestors); } +/** + * Resolves a suggested followup by posting the selected question as a comment + * and optimistically updating the HTML to mark the followup-list as resolved. + * @param report - The report where the action exists + * @param notifyReportID - The report ID to notify for new actions + * @param reportAction - The report action containing the followup-list + * @param selectedFollowup - The followup question selected by the user + * @param timezoneParam - The user's timezone + * @param ancestors - Array of ancestor reports for proper threading + */ +function resolveSuggestedFollowup( + report: OnyxEntry, + notifyReportID: string | undefined, + reportAction: OnyxEntry, + selectedFollowup: string, + timezoneParam: Timezone, + ancestors: Ancestor[] = [], +) { + const reportID = report?.reportID; + const reportActionID = reportAction?.reportActionID; + + if (!reportID || !reportActionID) { + return; + } + + const resolvedAction = buildOptimisticResolvedFollowups(reportAction); + + if (!resolvedAction) { + return; + } + + // Optimistically update the HTML to mark followup-list as resolved + Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`, { + [reportActionID]: resolvedAction, + }); + + // Post the selected followup question as a comment + addComment(report, notifyReportID ?? reportID, ancestors, selectedFollowup, timezoneParam); +} + /** * Enhances existing transaction thread reports with additional context for navigation * @@ -6534,6 +6622,7 @@ export { broadcastUserIsLeavingRoom, broadcastUserIsTyping, buildOptimisticChangePolicyData, + buildOptimisticResolvedFollowups, clearAddRoomMemberError, clearAvatarErrors, clearDeleteTransactionNavigateBackUrl, @@ -6596,6 +6685,7 @@ export { resolveActionableReportMentionWhisper, resolveConciergeCategoryOptions, resolveConciergeDescriptionOptions, + resolveSuggestedFollowup, savePrivateNotesDraft, saveReportActionDraft, saveReportDraftComment, diff --git a/src/pages/home/report/PureReportActionItem.tsx b/src/pages/home/report/PureReportActionItem.tsx index 8b7c54b4f66b7..d8fdf0e1669e6 100644 --- a/src/pages/home/report/PureReportActionItem.tsx +++ b/src/pages/home/report/PureReportActionItem.tsx @@ -63,6 +63,7 @@ import Parser from '@libs/Parser'; import Permissions from '@libs/Permissions'; import {getDisplayNameOrDefault} from '@libs/PersonalDetailsUtils'; import {getCleanedTagName, hasDynamicExternalWorkflow, isPolicyAdmin, isPolicyMember, isPolicyOwner} from '@libs/PolicyUtils'; +import {containsActionableFollowUps, parseFollowupsFromHtml} from '@libs/ReportActionsFollowupUtils'; import { extractLinksFromMessageHtml, getActionableCardFraudAlertMessage, @@ -202,6 +203,7 @@ import { resolveActionableMentionConfirmWhisper, resolveConciergeCategoryOptions, resolveConciergeDescriptionOptions, + resolveSuggestedFollowup, } from '@userActions/Report'; import type {IgnoreDirection} from '@userActions/ReportActions'; import {isAnonymousUser, signOutAndRedirectToSignIn} from '@userActions/Session'; @@ -875,6 +877,20 @@ function PureReportActionItem({ }, })); } + const messageHtml = getReportActionMessage(action)?.html; + if (messageHtml && reportActionReport) { + const followups = parseFollowupsFromHtml(messageHtml); + if (followups && followups.length > 0) { + return followups.map((followup) => ({ + text: followup.text, + shouldUseLocalization: false, + key: `${action.reportActionID}-followup-${followup.text}`, + onPress: () => { + resolveSuggestedFollowup(reportActionReport, reportID, action, followup.text, personalDetail.timezone ?? CONST.DEFAULT_TIME_ZONE); + }, + })); + } + } if (!isActionableWhisper && !isActionableCardFraudAlert(action) && (!isActionableJoinRequest(action) || getOriginalMessage(action)?.choice !== ('' as JoinWorkspaceResolution))) { return []; @@ -1645,6 +1661,14 @@ function PureReportActionItem({ ![CONST.MODERATION.MODERATOR_DECISION_APPROVED, CONST.MODERATION.MODERATOR_DECISION_PENDING].some((item) => item === moderationDecision) && !isPendingRemove(action); const isConciergeOptions = isConciergeCategoryOptions(action) || isConciergeDescriptionOptions(action); + const actionContainsFollowUps = containsActionableFollowUps(action); + let actionableButtonsNoLines = 1; + if (isConciergeOptions) { + actionableButtonsNoLines = 2; + } + if (actionContainsFollowUps) { + actionableButtonsNoLines = 0; + } children = ( @@ -1684,13 +1708,14 @@ function PureReportActionItem({ isActionableTrackExpense(action) || isConciergeCategoryOptions(action) || isConciergeDescriptionOptions(action) || - isActionableMentionWhisper(action) + isActionableMentionWhisper(action) || + actionContainsFollowUps ? 'vertical' : 'horizontal' } - shouldUseLocalization={!isConciergeOptions} - primaryTextNumberOfLines={isConciergeOptions ? 2 : 1} - textStyles={isConciergeOptions ? styles.textAlignLeft : undefined} + shouldUseLocalization={!isConciergeOptions && !actionContainsFollowUps} + primaryTextNumberOfLines={actionableButtonsNoLines} + textStyles={isConciergeOptions || actionContainsFollowUps ? styles.textAlignLeft : undefined} /> )} diff --git a/tests/actions/ReportTest.ts b/tests/actions/ReportTest.ts index 6ca7f152202f7..7cbac4fda7fda 100644 --- a/tests/actions/ReportTest.ts +++ b/tests/actions/ReportTest.ts @@ -28,6 +28,7 @@ import * as ReportUtils from '@src/libs/ReportUtils'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import type * as OnyxTypes from '@src/types/onyx'; +import type {Message} from '@src/types/onyx/ReportAction'; import createCollection from '../utils/collections/createCollection'; import createRandomPolicy from '../utils/collections/policies'; import createRandomReportAction from '../utils/collections/reportActions'; @@ -3594,4 +3595,145 @@ describe('actions/Report', () => { expect(Navigation.navigate).toHaveBeenCalledWith(ROUTES.REPORT_WITH_ID.getRoute(EXISTING_CHILD_REPORT.reportID)); }); }); + + describe('buildOptimisticResolvedFollowups', () => { + it('should return null when reportAction is undefined', () => { + const result = Report.buildOptimisticResolvedFollowups(undefined); + expect(result).toBeNull(); + }); + + it('should return null when reportAction has no followup-list', () => { + const reportAction = { + reportActionID: '123', + message: [{html: '

Hello world

', text: 'Hello world', type: CONST.REPORT.MESSAGE.TYPE.COMMENT}], + } as OnyxTypes.ReportAction; + + const result = Report.buildOptimisticResolvedFollowups(reportAction); + expect(result).toBeNull(); + }); + + it('should return null when followup-list is already resolved (has selected attribute)', () => { + const reportAction = { + reportActionID: '123', + message: [ + { + html: '

Message

Question?', + text: 'Message', + type: CONST.REPORT.MESSAGE.TYPE.COMMENT, + }, + ], + } as OnyxTypes.ReportAction; + + const result = Report.buildOptimisticResolvedFollowups(reportAction); + expect(result).toBeNull(); + }); + + it('should return updated action with resolved followup-list when unresolved followups exist', () => { + const reportAction = { + reportActionID: '123', + actorAccountID: CONST.ACCOUNT_ID.CONCIERGE, + message: [ + { + html: '

Here is some help

How do I set up QuickBooks?', + text: 'Here is some help', + type: CONST.REPORT.MESSAGE.TYPE.COMMENT, + }, + ], + } as OnyxTypes.ReportAction; + + const result = Report.buildOptimisticResolvedFollowups(reportAction); + + expect(result).not.toBeNull(); + + expect(result?.reportActionID).toBe('123'); + expect((result?.message as Message[]).at(0)?.html).toContain(''); + expect((result?.message as Message[]).at(0)?.html).not.toMatch(//); + }); + + it('should handle followup-list with attributes before adding selected', () => { + const reportAction = { + reportActionID: '456', + message: [ + { + html: '

Help

Question 1Question 2', + text: 'Help', + type: CONST.REPORT.MESSAGE.TYPE.COMMENT, + }, + ], + } as OnyxTypes.ReportAction; + + const result = Report.buildOptimisticResolvedFollowups(reportAction); + + expect(result).not.toBeNull(); + expect((result?.message as Message[]).at(0)?.html).toContain(''); + }); + }); + + describe('resolveSuggestedFollowup', () => { + const REPORT_ID = '12345'; + const REPORT_ACTION_ID = '67890'; + const report = { + reportID: REPORT_ID, + type: CONST.REPORT.TYPE.CHAT, + } as OnyxTypes.Report; + + it('should do nothing when reportAction has no unresolved followups', async () => { + const htmlMessage = '

Just a regular message

'; + const reportAction = { + reportActionID: REPORT_ACTION_ID, + message: [ + { + html: htmlMessage, + text: 'Just a regular message', + type: CONST.REPORT.MESSAGE.TYPE.COMMENT, + }, + ], + } as OnyxTypes.ReportAction; + + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${REPORT_ID}`, report); + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${REPORT_ID}`, { + [REPORT_ACTION_ID]: reportAction, + }); + await waitForBatchedUpdates(); + + Report.resolveSuggestedFollowup(report, undefined, reportAction, 'test question', CONST.DEFAULT_TIME_ZONE); + await waitForBatchedUpdates(); + + // The report action should remain unchanged (no followup-list to resolve) + const reportActions = await getOnyxValue(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${REPORT_ID}` as const); + expect((reportActions?.[REPORT_ACTION_ID]?.message as Message[])?.at(0)?.html).toBe(htmlMessage); + }); + + it('should optimistically resolve followups and post comment when unresolved followups exist', async () => { + const reportAction = { + reportActionID: REPORT_ACTION_ID, + actorAccountID: CONST.ACCOUNT_ID.CONCIERGE, + message: [ + { + html: '

Here is help

How do I set up QuickBooks?', + text: 'Here is help', + type: CONST.REPORT.MESSAGE.TYPE.COMMENT, + }, + ], + } as OnyxTypes.ReportAction; + + // Set up initial Onyx state + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${REPORT_ID}`, report); + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${REPORT_ID}`, { + [REPORT_ACTION_ID]: reportAction, + }); + await waitForBatchedUpdates(); + + Report.resolveSuggestedFollowup(report, undefined, reportAction, 'How do I set up QuickBooks?', CONST.DEFAULT_TIME_ZONE); + await waitForBatchedUpdates(); + + // Verify the followup-list was marked as selected + const reportActions = await getOnyxValue(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${REPORT_ID}` as const); + const updatedHtml = (reportActions?.[REPORT_ACTION_ID]?.message as Message[])?.at(0)?.html; + expect(updatedHtml).toContain(''); + + // Verify addComment was called (which triggers ADD_COMMENT API call) + TestHelper.expectAPICommandToHaveBeenCalled(WRITE_COMMANDS.ADD_COMMENT, 1); + }); + }); }); diff --git a/tests/ui/PureReportActionItemTest.tsx b/tests/ui/PureReportActionItemTest.tsx index 6174c013fe8f9..36c2f5ec9ff72 100644 --- a/tests/ui/PureReportActionItemTest.tsx +++ b/tests/ui/PureReportActionItemTest.tsx @@ -356,4 +356,139 @@ describe('PureReportActionItem', () => { expect(screen.queryByText(translateLocal('iou.queuedToSubmitViaDEW'))).not.toBeOnTheScreen(); }); }); + + describe('Followup list buttons', () => { + it('should display followup buttons when message contains unresolved followup-list', async () => { + const followupQuestion1 = 'How do I set up QuickBooks?'; + const followupQuestion2 = 'What is the Expensify Card cashback?'; + + const action = { + reportActionID: '12345', + actorAccountID: CONST.ACCOUNT_ID.CONCIERGE, + created: '2025-07-12 09:03:17.653', + actionName: CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT, + automatic: false, + shouldShow: true, + avatar: '', + person: [{type: 'TEXT', style: 'strong', text: 'Concierge'}], + message: [ + { + type: CONST.REPORT.MESSAGE.TYPE.COMMENT, + html: `

Here is some helpful information.

+ + ${followupQuestion1} + ${followupQuestion2} +`, + text: 'Here is some helpful information.', + }, + ], + originalMessage: {}, + } as ReportAction; + + const report = { + reportID: 'testReport', + type: CONST.REPORT.TYPE.CHAT, + }; + + render( + + + + + + + + + , + ); + await waitForBatchedUpdatesWithAct(); + + // Verify followup buttons are displayed + expect(screen.getByText(followupQuestion1)).toBeOnTheScreen(); + expect(screen.getByText(followupQuestion2)).toBeOnTheScreen(); + }); + + it('should not display followup buttons when followup-list is resolved (has selected attribute)', async () => { + const followupQuestion = 'How do I set up QuickBooks?'; + + const action = { + reportActionID: '12345', + actorAccountID: CONST.ACCOUNT_ID.CONCIERGE, + created: '2025-07-12 09:03:17.653', + actionName: CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT, + automatic: false, + shouldShow: true, + avatar: '', + person: [{type: 'TEXT', style: 'strong', text: 'Concierge'}], + message: [ + { + type: CONST.REPORT.MESSAGE.TYPE.COMMENT, + html: `

Here is some helpful information.

+ + ${followupQuestion} +`, + text: 'Here is some helpful information.', + }, + ], + originalMessage: {}, + } as ReportAction; + + const report = { + reportID: 'testReport', + type: CONST.REPORT.TYPE.CHAT, + }; + + render( + + + + + + + + + , + ); + await waitForBatchedUpdatesWithAct(); + + // Verify followup buttons are NOT displayed (resolved state) + expect(screen.queryByText(followupQuestion)).not.toBeOnTheScreen(); + }); + }); }); diff --git a/tests/unit/FollowupUtilsTest.ts b/tests/unit/FollowupUtilsTest.ts new file mode 100644 index 0000000000000..0124097b454ef --- /dev/null +++ b/tests/unit/FollowupUtilsTest.ts @@ -0,0 +1,160 @@ +import CONST from '../../src/CONST'; +import {containsActionableFollowUps, parseFollowupsFromHtml} from '../../src/libs/ReportActionsFollowupUtils'; +import {stripFollowupListFromHtml} from '../../src/libs/ReportActionsUtils'; +import type {ReportAction} from '../../src/types/onyx'; + +describe('FollowupUtils', () => { + describe('parseFollowupsFromHtml', () => { + it('should return null when no followup-list exists', () => { + const html = '

Hello world

'; + expect(parseFollowupsFromHtml(html)).toBeNull(); + }); + + it('should return null for empty string', () => { + expect(parseFollowupsFromHtml('')).toBeNull(); + }); + + it('should return empty array when followup-list has selected attribute', () => { + const html = `

Some message

+ + How do I set up QuickBooks? +`; + expect(parseFollowupsFromHtml(html)).toEqual([]); + }); + + it('should return empty array when followup-list has selected attribute with other attributes', () => { + const html = ` + Question 1 +`; + expect(parseFollowupsFromHtml(html)).toEqual([]); + }); + + it('should parse single followup from unresolved list', () => { + const html = `

Hello

+ + How do I set up QuickBooks? +`; + expect(parseFollowupsFromHtml(html)).toEqual([{text: 'How do I set up QuickBooks?'}]); + }); + + it('should parse multiple followups from unresolved list', () => { + const html = ` + How do I set up QuickBooks? + What is the Expensify Card cashback? +`; + expect(parseFollowupsFromHtml(html)).toEqual([{text: 'How do I set up QuickBooks?'}, {text: 'What is the Expensify Card cashback?'}]); + }); + + it('should handle followup-list with whitespace attributes', () => { + const html = ` + Question +`; + expect(parseFollowupsFromHtml(html)).toEqual([{text: 'Question'}]); + }); + + it('should return empty array for followup-list with selected but no followups', () => { + const html = ''; + expect(parseFollowupsFromHtml(html)).toEqual([]); + }); + + it('should return empty array for unresolved followup-list with no followups', () => { + const html = ''; + expect(parseFollowupsFromHtml(html)).toEqual([]); + }); + }); + + describe('stripFollowupListFromHtml', () => { + it('should return original string when no followup-list exists', () => { + const html = '

Hello world

'; + expect(stripFollowupListFromHtml(html)).toBe('

Hello world

'); + }); + + it('should return undefined for empty input', () => { + expect(stripFollowupListFromHtml('')).not.toBeDefined(); + }); + + it('should strip followup-list and trim result', () => { + const html = `

Some message

+ + How do I set up QuickBooks? +`; + expect(stripFollowupListFromHtml(html)).toBe('

Some message

'); + }); + + it('should strip resolved followup-list with selected attribute', () => { + const html = `

Answer to your question

+ + Old question +`; + expect(stripFollowupListFromHtml(html)).toBe('

Answer to your question

'); + }); + + it('should handle content before and after followup-list', () => { + const html = `

Before

+ + Question + +

After

`; + expect(stripFollowupListFromHtml(html)).toBe(`

Before

+ +

After

`); + }); + }); + + describe('containsActionableFollowUps', () => { + it('should return false for null/undefined reportAction', () => { + expect(containsActionableFollowUps(null)).toBe(false); + expect(containsActionableFollowUps(undefined)).toBe(false); + }); + + it('should return false for non-ADD_COMMENT action types', () => { + const action = { + reportActionID: '123', + actionName: CONST.REPORT.ACTIONS.TYPE.CREATED, + message: [{html: 'Question', text: '', type: 'COMMENT'}], + } as ReportAction; + + expect(containsActionableFollowUps(action)).toBe(false); + }); + + it('should return false for ADD_COMMENT without message html', () => { + const action = { + reportActionID: '123', + actionName: CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT, + message: [{text: 'Just text', type: 'COMMENT'}], + } as ReportAction; + + expect(containsActionableFollowUps(action)).toBe(false); + }); + + it('should return false for ADD_COMMENT without followup-list', () => { + const action = { + reportActionID: '123', + actionName: CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT, + message: [{html: '

Regular message

', text: 'Regular message', type: 'COMMENT'}], + } as ReportAction; + + expect(containsActionableFollowUps(action)).toBe(false); + }); + + it('should return false for ADD_COMMENT with resolved followup-list (selected attribute)', () => { + const action = { + reportActionID: '123', + actionName: CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT, + message: [{html: '

Message

Question', text: 'Message', type: 'COMMENT'}], + } as ReportAction; + + expect(containsActionableFollowUps(action)).toBe(false); + }); + + it('should return true for ADD_COMMENT with unresolved followup-list', () => { + const action = { + reportActionID: '123', + actionName: CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT, + message: [{html: '

Message

Question', text: 'Message', type: 'COMMENT'}], + } as ReportAction; + + expect(containsActionableFollowUps(action)).toBe(true); + }); + }); +});