From 9e1d371f6f1e3dbb3cf65217d394cd0cb2c09947 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Musia=C5=82?= Date: Wed, 28 Jan 2026 12:06:25 -0800 Subject: [PATCH 1/8] refactor --- .../index.ts} | 33 +++++------- .../stripFollowupListFromHtml.ts | 16 ++++++ src/libs/ReportActionsUtils.ts | 16 +----- src/libs/actions/Report.ts | 6 +-- src/libs/actions/Report/SuggestedFollowup.ts | 50 +++++++++++++++++++ .../inbox/report/PureReportActionItem.tsx | 3 +- ...t.ts => ReportActionsFollowupUtilsTest.ts} | 4 +- 7 files changed, 87 insertions(+), 41 deletions(-) rename src/libs/{ReportActionsFollowupUtils.ts => ReportActionFollowupUtils/index.ts} (52%) create mode 100644 src/libs/ReportActionFollowupUtils/stripFollowupListFromHtml.ts create mode 100644 src/libs/actions/Report/SuggestedFollowup.ts rename tests/unit/{FollowupUtilsTest.ts => ReportActionsFollowupUtilsTest.ts} (98%) diff --git a/src/libs/ReportActionsFollowupUtils.ts b/src/libs/ReportActionFollowupUtils/index.ts similarity index 52% rename from src/libs/ReportActionsFollowupUtils.ts rename to src/libs/ReportActionFollowupUtils/index.ts index e068a3935daea..d1735bae5155b 100644 --- a/src/libs/ReportActionsFollowupUtils.ts +++ b/src/libs/ReportActionFollowupUtils/index.ts @@ -1,7 +1,8 @@ +import {DomUtils, parseDocument} from 'htmlparser2'; +import type {Followup} from '@libs/ReportActionsUtils'; +import {getReportActionMessage, isActionOfType} from '@libs/ReportActionsUtils'; 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. @@ -22,36 +23,28 @@ function containsActionableFollowUps(reportAction: OnyxInputOrEntry 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) { + const doc = parseDocument(html); + const followupListElements = DomUtils.getElementsByTagName('followup-list', doc, true); + if (followupListElements.length === 0) { 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) { + const followupList = followupListElements.at(0); + if (!followupList) { + return null; + } + if (DomUtils.hasAttrib(followupList, 'selected')) { 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; + const followupTextElements = DomUtils.getElementsByTagName('followup-text', followupList, true); + return followupTextElements.map((el) => ({text: DomUtils.textContent(el)})); } export {containsActionableFollowUps, parseFollowupsFromHtml}; diff --git a/src/libs/ReportActionFollowupUtils/stripFollowupListFromHtml.ts b/src/libs/ReportActionFollowupUtils/stripFollowupListFromHtml.ts new file mode 100644 index 0000000000000..a48fa01b56102 --- /dev/null +++ b/src/libs/ReportActionFollowupUtils/stripFollowupListFromHtml.ts @@ -0,0 +1,16 @@ +/** + * 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(); +} + +export default stripFollowupListFromHtml; diff --git a/src/libs/ReportActionsUtils.ts b/src/libs/ReportActionsUtils.ts index 9855c7e0ef290..35cbee97a58c7 100644 --- a/src/libs/ReportActionsUtils.ts +++ b/src/libs/ReportActionsUtils.ts @@ -43,6 +43,7 @@ import getReportURLForCurrentContext from './Navigation/helpers/getReportURLForC import Parser from './Parser'; import {arePersonalDetailsMissing, getEffectiveDisplayName, getPersonalDetailByEmail, getPersonalDetailsByIDs} from './PersonalDetailsUtils'; import {getPolicy, isPolicyAdmin as isPolicyAdminPolicyUtils} from './PolicyUtils'; +import stripFollowupListFromHtml from './ReportActionFollowupUtils/stripFollowupListFromHtml'; import type {getReportName, OptimisticIOUReportAction, PartialReportAction} from './ReportUtils'; import StringUtils from './StringUtils'; import {getReportFieldTypeTranslationKey} from './WorkspaceReportFieldUtils'; @@ -1734,21 +1735,6 @@ 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 ?? ''; } diff --git a/src/libs/actions/Report.ts b/src/libs/actions/Report.ts index c5597c8a55d27..4715b4e9ea858 100644 --- a/src/libs/actions/Report.ts +++ b/src/libs/actions/Report.ts @@ -102,9 +102,8 @@ 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 ReportActionsFollowupUtils from '@libs/ReportActionFollowupUtils'; 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 { @@ -555,6 +554,7 @@ function buildOptimisticResolvedFollowups(reportAction: OnyxEntry) return null; } + // Mark followup-list as selected after a comment has been posted below the follow up list comment const updatedHtml = html.replace(/]*)?>/, ''); return { ...reportAction, @@ -644,7 +644,7 @@ function addActions(report: OnyxEntry, notifyReportID: string, ancestors // 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 lastVisibleAction = ReportActionsUtils.getLastVisibleAction(reportID); const lastActorAccountID = lastVisibleAction?.actorAccountID; const lastActionReportActionID = lastVisibleAction?.reportActionID; const resolvedAction = buildOptimisticResolvedFollowups(lastVisibleAction); diff --git a/src/libs/actions/Report/SuggestedFollowup.ts b/src/libs/actions/Report/SuggestedFollowup.ts new file mode 100644 index 0000000000000..d45f81ea47be2 --- /dev/null +++ b/src/libs/actions/Report/SuggestedFollowup.ts @@ -0,0 +1,50 @@ +import type {OnyxEntry} from 'react-native-onyx'; +import Onyx from 'react-native-onyx'; +import type {Ancestor} from '@libs/ReportUtils'; +// eslint-disable-next-line @dword-design/import-alias/prefer-alias +import {addComment, buildOptimisticResolvedFollowups} from '@userActions/Report'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {Report, ReportAction} from '@src/types/onyx'; +import type {Timezone} from '@src/types/onyx/PersonalDetails'; + +/** + * 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); +} + +export default resolveSuggestedFollowup; diff --git a/src/pages/inbox/report/PureReportActionItem.tsx b/src/pages/inbox/report/PureReportActionItem.tsx index 3f50d32424f48..d72f919d0b356 100644 --- a/src/pages/inbox/report/PureReportActionItem.tsx +++ b/src/pages/inbox/report/PureReportActionItem.tsx @@ -63,7 +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 {containsActionableFollowUps, parseFollowupsFromHtml} from '@libs/ReportActionFollowupUtils'; import { extractLinksFromMessageHtml, getActionableCardFraudAlertMessage, @@ -1723,6 +1723,7 @@ function PureReportActionItem({ shouldUseLocalization={!isConciergeOptions && !actionContainsFollowUps} primaryTextNumberOfLines={actionableButtonsNoLines} textStyles={isConciergeOptions || actionContainsFollowUps ? styles.textAlignLeft : undefined} + buttonStyles={styles.actionableItemButton} /> )} diff --git a/tests/unit/FollowupUtilsTest.ts b/tests/unit/ReportActionsFollowupUtilsTest.ts similarity index 98% rename from tests/unit/FollowupUtilsTest.ts rename to tests/unit/ReportActionsFollowupUtilsTest.ts index 0124097b454ef..cc295b0df79ac 100644 --- a/tests/unit/FollowupUtilsTest.ts +++ b/tests/unit/ReportActionsFollowupUtilsTest.ts @@ -1,9 +1,9 @@ import CONST from '../../src/CONST'; -import {containsActionableFollowUps, parseFollowupsFromHtml} from '../../src/libs/ReportActionsFollowupUtils'; +import {containsActionableFollowUps, parseFollowupsFromHtml} from '../../src/libs/ReportActionFollowupUtils'; import {stripFollowupListFromHtml} from '../../src/libs/ReportActionsUtils'; import type {ReportAction} from '../../src/types/onyx'; -describe('FollowupUtils', () => { +describe('ReportActionsFollowupUtils', () => { describe('parseFollowupsFromHtml', () => { it('should return null when no followup-list exists', () => { const html = '

Hello world

'; From 04f812c9b7c5e2f3e7961980fe3ffb82d7feec3e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Musia=C5=82?= Date: Wed, 28 Jan 2026 12:37:07 -0800 Subject: [PATCH 2/8] button styes for actionable followups --- .../ReportActionItem/ActionableItemButtons.tsx | 7 +++++-- src/pages/inbox/report/PureReportActionItem.tsx | 3 ++- src/styles/index.ts | 9 +++++++++ 3 files changed, 16 insertions(+), 3 deletions(-) diff --git a/src/components/ReportActionItem/ActionableItemButtons.tsx b/src/components/ReportActionItem/ActionableItemButtons.tsx index aea273cc8513f..20e14a1b61d33 100644 --- a/src/components/ReportActionItem/ActionableItemButtons.tsx +++ b/src/components/ReportActionItem/ActionableItemButtons.tsx @@ -1,6 +1,6 @@ import React from 'react'; import {View} from 'react-native'; -import type {StyleProp, TextStyle} from 'react-native'; +import type {StyleProp, TextStyle, ViewStyle} from 'react-native'; import Button from '@components/Button'; import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -20,6 +20,8 @@ type ActionableItemButtonsProps = { shouldUseLocalization?: boolean; primaryTextNumberOfLines?: number; textStyles?: StyleProp; + buttonStyles?: StyleProp; + containerStyles?: StyleProp; }; function ActionableItemButtons(props: ActionableItemButtonsProps) { @@ -27,7 +29,7 @@ function ActionableItemButtons(props: ActionableItemButtonsProps) { const {translate} = useLocalize(); return ( - + {props.items?.map((item) => (