diff --git a/src/components/LHNOptionsList/OptionRowLHN.js b/src/components/LHNOptionsList/OptionRowLHN.js index f75e3390136a..fc4f05eefd22 100644 --- a/src/components/LHNOptionsList/OptionRowLHN.js +++ b/src/components/LHNOptionsList/OptionRowLHN.js @@ -135,7 +135,7 @@ function OptionRowLHN(props) { props.reportID, '0', props.reportID, - '', + undefined, () => {}, () => setIsContextMenuActive(false), false, diff --git a/src/components/ShowContextMenuContext.js b/src/components/ShowContextMenuContext.js index 28822451956d..04ccd5002b60 100644 --- a/src/components/ShowContextMenuContext.js +++ b/src/components/ShowContextMenuContext.js @@ -35,7 +35,7 @@ function showContextMenuForReport(event, anchor, reportID, action, checkIfContex reportID, action.reportActionID, ReportUtils.getOriginalReportID(reportID, action), - '', + undefined, checkIfContextMenuActive, checkIfContextMenuActive, isArchivedRoom, diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index 24d0050997f7..6a4914f44121 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -1180,7 +1180,7 @@ function formatReportLastMessageText(lastMessageText: string, isModifiedExpenseM if (isModifiedExpenseMessage) { return String(lastMessageText).trim().replace(CONST.REGEX.LINE_BREAK, '').trim(); } - return String(lastMessageText).trim().replace(CONST.REGEX.AFTER_FIRST_LINE_BREAK, '').substring(0, CONST.REPORT.LAST_MESSAGE_TEXT_MAX_LENGTH).trim(); + return String(lastMessageText).trim().replace(CONST.REGEX.LINE_BREAK, ' ').substring(0, CONST.REPORT.LAST_MESSAGE_TEXT_MAX_LENGTH).trim(); } /** diff --git a/src/libs/actions/Report.ts b/src/libs/actions/Report.ts index 06c0316a40b5..ef24b64ce7c7 100644 --- a/src/libs/actions/Report.ts +++ b/src/libs/actions/Report.ts @@ -1355,10 +1355,16 @@ function editReportComment(reportID: string, originalReportAction: OnyxEntry { // Add all migrations to an array so they are executed in order - const migrationPromises = [PersonalDetailsByAccountID, RenameReceiptFilename, KeyReportActionsDraftByReportActionID, TransactionBackupsToCollection]; + const migrationPromises = [PersonalDetailsByAccountID, RenameReceiptFilename, KeyReportActionsDraftByReportActionID, TransactionBackupsToCollection, RemoveEmptyReportActionsDrafts]; // Reduce all promises down to a single promise. All promises run in a linear fashion, waiting for the // previous promise to finish before moving onto the next one. diff --git a/src/libs/migrations/RemoveEmptyReportActionsDrafts.ts b/src/libs/migrations/RemoveEmptyReportActionsDrafts.ts new file mode 100644 index 000000000000..d8816198e537 --- /dev/null +++ b/src/libs/migrations/RemoveEmptyReportActionsDrafts.ts @@ -0,0 +1,76 @@ +import _ from 'lodash'; +import Onyx, {OnyxEntry} from 'react-native-onyx'; +import Log from '@libs/Log'; +import ONYXKEYS from '@src/ONYXKEYS'; +import {ReportActionsDraft, ReportActionsDrafts} from '@src/types/onyx'; +import {isEmptyObject} from '@src/types/utils/EmptyObject'; + +type ReportActionsDraftsKey = `${typeof ONYXKEYS.COLLECTION.REPORT_ACTIONS_DRAFTS}${string}`; + +/** + * This migration removes empty drafts from reportActionsDrafts, which was previously used to mark a draft as being non-existent (e.g. upon cancel). + */ +export default function (): Promise { + return new Promise((resolve) => { + const connectionID = Onyx.connect({ + key: ONYXKEYS.COLLECTION.REPORT_ACTIONS_DRAFTS, + waitForCollectionCallback: true, + callback: (allReportActionsDrafts) => { + Onyx.disconnect(connectionID); + + if (!allReportActionsDrafts) { + Log.info('[Migrate Onyx] Skipped migration RemoveEmptyReportActionsDrafts because there were no reportActionsDrafts'); + return resolve(); + } + + const newReportActionsDrafts: Record> = {}; + Object.entries(allReportActionsDrafts).forEach(([onyxKey, reportActionDrafts]) => { + const newReportActionsDraftsForReport: Record = {}; + + // Whether there is at least one draft in this report that has to be migrated + let hasUnmigratedDraft = false; + + if (reportActionDrafts) { + Object.entries(reportActionDrafts).forEach(([reportActionID, reportActionDraft]) => { + // If the draft is a string, it means it hasn't been migrated yet + if (typeof reportActionDraft === 'string') { + hasUnmigratedDraft = true; + Log.info(`[Migrate Onyx] Migrating draft for report action ${reportActionID}`); + + if (_.isEmpty(reportActionDraft)) { + Log.info(`[Migrate Onyx] Removing draft for report action ${reportActionID}`); + return; + } + + newReportActionsDraftsForReport[reportActionID] = {message: reportActionDraft}; + } else { + // We've already migrated this draft, so keep the existing value + newReportActionsDraftsForReport[reportActionID] = reportActionDraft; + } + }); + } + + if (isEmptyObject(newReportActionsDraftsForReport)) { + Log.info('[Migrate Onyx] NO REMAINING'); + // Clear if there are no drafts remaining + newReportActionsDrafts[onyxKey as ReportActionsDraftsKey] = null; + } else if (hasUnmigratedDraft) { + // Only migrate if there are unmigrated drafts, there's no need to overwrite this onyx key with the same data + newReportActionsDrafts[onyxKey as ReportActionsDraftsKey] = newReportActionsDraftsForReport; + } + }); + + if (isEmptyObject(newReportActionsDrafts)) { + Log.info('[Migrate Onyx] Skipped migration RemoveEmptyReportActionsDrafts because there are no actions drafts to migrate'); + return resolve(); + } + + Log.info(`[Migrate Onyx] Updating drafts for ${Object.keys(newReportActionsDrafts).length} reports`); + Onyx.multiSet(newReportActionsDrafts).then(() => { + Log.info('[Migrate Onyx] Ran migration RemoveEmptyReportActionsDrafts successfully'); + resolve(); + }); + }, + }); + }); +} diff --git a/src/pages/home/report/ContextMenu/ContextMenuActions.js b/src/pages/home/report/ContextMenu/ContextMenuActions.js index 7365d673dc50..b2e74a2b7cbf 100644 --- a/src/pages/home/report/ContextMenu/ContextMenuActions.js +++ b/src/pages/home/report/ContextMenu/ContextMenuActions.js @@ -380,7 +380,13 @@ export default [ Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(childReportID)); return; } - const editAction = () => Report.saveReportActionDraft(reportID, reportAction, _.isEmpty(draftMessage) ? getActionText(reportAction) : ''); + const editAction = () => { + if (_.isUndefined(draftMessage)) { + Report.saveReportActionDraft(reportID, reportAction, getActionText(reportAction)); + } else { + Report.deleteReportActionDraft(reportID, reportAction); + } + }; if (closePopover) { // Hide popover, then call editAction diff --git a/src/pages/home/report/ContextMenu/PopoverReportActionContextMenu.js b/src/pages/home/report/ContextMenu/PopoverReportActionContextMenu.js index 30c68dd52a14..1c93c3bc90c7 100644 --- a/src/pages/home/report/ContextMenu/PopoverReportActionContextMenu.js +++ b/src/pages/home/report/ContextMenu/PopoverReportActionContextMenu.js @@ -17,7 +17,7 @@ function PopoverReportActionContextMenu(_props, ref) { const reportActionIDRef = useRef('0'); const originalReportIDRef = useRef('0'); const selectionRef = useRef(''); - const reportActionDraftMessageRef = useRef(''); + const reportActionDraftMessageRef = useRef(undefined); const cursorRelativePosition = useRef({ horizontal: 0, @@ -226,7 +226,7 @@ function PopoverReportActionContextMenu(_props, ref) { } selectionRef.current = ''; - reportActionDraftMessageRef.current = ''; + reportActionDraftMessageRef.current = undefined; setIsPopoverVisible(false); }; diff --git a/src/pages/home/report/ContextMenu/ReportActionContextMenu.ts b/src/pages/home/report/ContextMenu/ReportActionContextMenu.ts index b269bc276b55..1e1fc700d8e0 100644 --- a/src/pages/home/report/ContextMenu/ReportActionContextMenu.ts +++ b/src/pages/home/report/ContextMenu/ReportActionContextMenu.ts @@ -98,7 +98,7 @@ function showContextMenu( reportID = '0', reportActionID = '0', originalReportID = '0', - draftMessage = '', + draftMessage = undefined, onShow = () => {}, onHide = () => {}, isArchivedRoom = false, diff --git a/src/pages/home/report/ContextMenu/genericReportActionContextMenuPropTypes.js b/src/pages/home/report/ContextMenu/genericReportActionContextMenuPropTypes.js index 3d8667e44e62..b9f892c1b9ff 100644 --- a/src/pages/home/report/ContextMenu/genericReportActionContextMenuPropTypes.js +++ b/src/pages/home/report/ContextMenu/genericReportActionContextMenuPropTypes.js @@ -28,7 +28,7 @@ const defaultProps = { isMini: false, isVisible: false, selection: '', - draftMessage: '', + draftMessage: undefined, }; export {propTypes, defaultProps}; diff --git a/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.js b/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.js index 0edee4b48241..c2e8234ab23f 100644 --- a/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.js +++ b/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.js @@ -5,6 +5,7 @@ import {findNodeHandle, InteractionManager, NativeModules, View} from 'react-nat import {withOnyx} from 'react-native-onyx'; import _ from 'underscore'; import Composer from '@components/Composer'; +import {PopoverContext} from '@components/PopoverProvider'; import withKeyboardState from '@components/withKeyboardState'; import useDebounce from '@hooks/useDebounce'; import useLocalize from '@hooks/useLocalize'; @@ -106,6 +107,7 @@ function ComposerWithSuggestions({ // For testing children, }) { + const {isOpen: isPopoverOpen} = React.useContext(PopoverContext); const theme = useTheme(); const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); @@ -427,9 +429,15 @@ function ComposerWithSuggestions({ * @param {Boolean} [shouldDelay=false] Impose delay before focusing the composer * @memberof ReportActionCompose */ - const focus = useCallback((shouldDelay = false) => { - focusComposerWithDelay(textInputRef.current)(shouldDelay); - }, []); + const focus = useCallback( + (shouldDelay = false) => { + if (isPopoverOpen) { + return; + } + focusComposerWithDelay(textInputRef.current)(shouldDelay); + }, + [isPopoverOpen], + ); const setUpComposeFocusManager = useCallback(() => { // This callback is used in the contextMenuActions to manage giving focus back to the compose input. diff --git a/src/pages/home/report/ReportActionItem.js b/src/pages/home/report/ReportActionItem.js index e2a345d33a32..a7972b97f5c1 100644 --- a/src/pages/home/report/ReportActionItem.js +++ b/src/pages/home/report/ReportActionItem.js @@ -120,7 +120,7 @@ const propTypes = { }; const defaultProps = { - draftMessage: '', + draftMessage: undefined, preferredSkinTone: CONST.EMOJI_DEFAULT_SKIN_TONE, emojiReactions: {}, shouldShowSubscriptAvatar: false, @@ -197,7 +197,7 @@ function ReportActionItem(props) { }, [isDeletedParentAction, props.action.reportActionID]); useEffect(() => { - if (prevDraftMessage || !props.draftMessage) { + if (!_.isUndefined(prevDraftMessage) || _.isUndefined(props.draftMessage)) { return; } @@ -219,10 +219,10 @@ function ReportActionItem(props) { }, [props.action, props.report.reportID]); useEffect(() => { - if (!props.draftMessage || !ReportActionsUtils.isDeletedAction(props.action)) { + if (_.isUndefined(props.draftMessage) || !ReportActionsUtils.isDeletedAction(props.action)) { return; } - Report.saveReportActionDraft(props.report.reportID, props.action, ''); + Report.deleteReportActionDraft(props.report.reportID, props.action); }, [props.draftMessage, props.action, props.report.reportID]); // Hide the message if it is being moderated for a higher offense, or is hidden by a moderator @@ -260,7 +260,7 @@ function ReportActionItem(props) { const showPopover = useCallback( (event) => { // Block menu on the message being Edited or if the report action item has errors - if (props.draftMessage || !_.isEmpty(props.action.errors)) { + if (!_.isUndefined(props.draftMessage) || !_.isEmpty(props.action.errors)) { return; } @@ -426,7 +426,7 @@ function ReportActionItem(props) { const hasBeenFlagged = !_.contains([CONST.MODERATION.MODERATOR_DECISION_APPROVED, CONST.MODERATION.MODERATOR_DECISION_PENDING], moderationDecision); children = ( - {!props.draftMessage ? ( + {_.isUndefined(props.draftMessage) ? ( Number(accountID)); - const draftMessageRightAlign = props.draftMessage ? styles.chatItemReactionsDraftRight : {}; + const draftMessageRightAlign = !_.isUndefined(props.draftMessage) ? styles.chatItemReactionsDraftRight : {}; return ( <> {children} {Permissions.canUseLinkPreviews() && !isHidden && !_.isEmpty(props.action.linkMetadata) && ( - + !_.isEmpty(item))} /> )} @@ -542,7 +542,7 @@ function ReportActionItem(props) { const renderReportActionItem = (hovered, isWhisper, hasErrors) => { const content = renderItemContent(hovered || isContextMenuActive, isWhisper, hasErrors); - if (props.draftMessage) { + if (!_.isUndefined(props.draftMessage)) { return {content}; } @@ -550,7 +550,7 @@ function ReportActionItem(props) { return ( ${props.translate('parentReportAction.deletedTask')}`} /> @@ -664,13 +664,13 @@ function ReportActionItem(props) { onPressIn={() => props.isSmallScreenWidth && DeviceCapabilities.canUseTouchScreen() && ControlSelection.block()} onPressOut={() => ControlSelection.unblock()} onSecondaryInteraction={showPopover} - preventDefaultContextMenu={!props.draftMessage && !hasErrors} + preventDefaultContextMenu={_.isUndefined(props.draftMessage) && !hasErrors} withoutFocusOnSecondaryInteraction accessibilityLabel={props.translate('accessibilityHints.chatMessage')} > {(hovered) => ( @@ -681,14 +681,14 @@ function ReportActionItem(props) { originalReportID={originalReportID} isArchivedRoom={ReportUtils.isArchivedRoom(props.report)} displayAsGroup={props.displayAsGroup} - isVisible={hovered && !props.draftMessage && !hasErrors} + isVisible={hovered && _.isUndefined(props.draftMessage) && !hasErrors} draftMessage={props.draftMessage} isChronosReport={ReportUtils.chatIncludesChronos(originalReport)} /> - + ReportActions.clearReportActionErrors(props.report.reportID, props.action)} - pendingAction={props.draftMessage ? null : props.action.pendingAction} + pendingAction={!_.isUndefined(props.draftMessage) ? null : props.action.pendingAction} shouldHideOnDelete={!ReportActionsUtils.isThreadParentMessage(props.action, props.report.reportID)} errors={props.action.errors} errorRowStyles={[styles.ml10, styles.mr2]} @@ -744,7 +744,7 @@ export default compose( transformValue: (drafts, props) => { const originalReportID = ReportUtils.getOriginalReportID(props.report.reportID, props.action); const draftKey = `${ONYXKEYS.COLLECTION.REPORT_ACTIONS_DRAFTS}${originalReportID}`; - return lodashGet(drafts, [draftKey, props.action.reportActionID], ''); + return lodashGet(drafts, [draftKey, props.action.reportActionID, 'message']); }, }), withOnyx({ diff --git a/src/pages/home/report/ReportActionItemMessageEdit.js b/src/pages/home/report/ReportActionItemMessageEdit.js index d2b22921a15d..dbd3262f30d5 100644 --- a/src/pages/home/report/ReportActionItemMessageEdit.js +++ b/src/pages/home/report/ReportActionItemMessageEdit.js @@ -270,16 +270,10 @@ function ReportActionItemMessageEdit(props) { draftRef.current = newDraft; - // This component is rendered only when draft is set to a non-empty string. In order to prevent component - // unmount when user deletes content of textarea, we set previous message instead of empty string. - if (newDraft.trim().length > 0) { - // We want to escape the draft message to differentiate the HTML from the report action and the HTML the user drafted. - debouncedSaveDraft(_.escape(newDraft)); - } else { - debouncedSaveDraft(props.action.message[0].html); - } + // We want to escape the draft message to differentiate the HTML from the report action and the HTML the user drafted. + debouncedSaveDraft(_.escape(newDraft)); }, - [props.action.message, debouncedSaveDraft, debouncedUpdateFrequentlyUsedEmojis, props.preferredSkinTone, preferredLocale, selection.end], + [debouncedSaveDraft, debouncedUpdateFrequentlyUsedEmojis, props.preferredSkinTone, preferredLocale, selection.end], ); useEffect(() => { @@ -292,7 +286,7 @@ function ReportActionItemMessageEdit(props) { */ const deleteDraft = useCallback(() => { debouncedSaveDraft.cancel(); - Report.saveReportActionDraft(props.reportID, props.action, ''); + Report.deleteReportActionDraft(props.reportID, props.action); if (isActive()) { ReportActionComposeFocusManager.clear(); diff --git a/src/types/onyx/ReportActionsDraft.ts b/src/types/onyx/ReportActionsDraft.ts new file mode 100644 index 000000000000..41a701f16e71 --- /dev/null +++ b/src/types/onyx/ReportActionsDraft.ts @@ -0,0 +1,7 @@ +type ReportActionsDraft = + | { + message: string; + } + | string; + +export default ReportActionsDraft; diff --git a/src/types/onyx/ReportActionsDrafts.ts b/src/types/onyx/ReportActionsDrafts.ts index e40007b6b47a..ad2782111144 100644 --- a/src/types/onyx/ReportActionsDrafts.ts +++ b/src/types/onyx/ReportActionsDrafts.ts @@ -1,3 +1,5 @@ -type ReportActionsDrafts = Record; +import ReportActionsDraft from './ReportActionsDraft'; + +type ReportActionsDrafts = Record; export default ReportActionsDrafts; diff --git a/src/types/onyx/index.ts b/src/types/onyx/index.ts index 3d4eef500f1d..efa1ae1fe630 100644 --- a/src/types/onyx/index.ts +++ b/src/types/onyx/index.ts @@ -36,6 +36,7 @@ import ReimbursementAccountDraft from './ReimbursementAccountDraft'; import Report from './Report'; import ReportAction, {ReportActions} from './ReportAction'; import ReportActionReactions from './ReportActionReactions'; +import ReportActionsDraft from './ReportActionsDraft'; import ReportActionsDrafts from './ReportActionsDrafts'; import ReportMetadata from './ReportMetadata'; import ReportNextStep from './ReportNextStep'; @@ -105,6 +106,7 @@ export type { ReportAction, ReportActionReactions, ReportActions, + ReportActionsDraft, ReportActionsDrafts, ReportMetadata, ReportNextStep, diff --git a/tests/unit/CalendarPickerTest.js b/tests/unit/CalendarPickerTest.js index 9c5ea4bc72f6..e1c11fbb8ca8 100644 --- a/tests/unit/CalendarPickerTest.js +++ b/tests/unit/CalendarPickerTest.js @@ -1,5 +1,5 @@ import {fireEvent, render, within} from '@testing-library/react-native'; -import {addMonths, addYears, subYears} from 'date-fns'; +import {addMonths, addYears, subMonths, subYears} from 'date-fns'; import CalendarPicker from '../../src/components/DatePicker/CalendarPicker'; import CONST from '../../src/CONST'; import DateUtils from '../../src/libs/DateUtils'; @@ -73,7 +73,7 @@ describe('CalendarPicker', () => { fireEvent.press(getByTestId('prev-month-arrow')); - const prevMonth = new Date().getMonth() - 1; + const prevMonth = subMonths(new Date(), 1).getMonth(); expect(getByText(monthNames[prevMonth])).toBeTruthy(); });