From 99c70920e29748b81f8aef54a607c3d9c3cfc51d Mon Sep 17 00:00:00 2001 From: dukenv0307 Date: Mon, 15 Jan 2024 10:43:47 +0700 Subject: [PATCH 01/24] test --- .../ComposerWithSuggestionsEdit.tsx | 49 +++++++++++++++++++ .../report/ReportActionItemMessageEdit.tsx | 3 +- 2 files changed, 51 insertions(+), 1 deletion(-) create mode 100644 src/pages/home/report/ReportActionCompose/ComposerWithSuggestionsEdit/ComposerWithSuggestionsEdit.tsx diff --git a/src/pages/home/report/ReportActionCompose/ComposerWithSuggestionsEdit/ComposerWithSuggestionsEdit.tsx b/src/pages/home/report/ReportActionCompose/ComposerWithSuggestionsEdit/ComposerWithSuggestionsEdit.tsx new file mode 100644 index 000000000000..2d742261419d --- /dev/null +++ b/src/pages/home/report/ReportActionCompose/ComposerWithSuggestionsEdit/ComposerWithSuggestionsEdit.tsx @@ -0,0 +1,49 @@ +import { ComposerProps } from "@components/Composer/types"; +import Composer from "@components/Composer"; +import React, { ForwardedRef } from 'react'; +import { AnimatedProps } from "react-native-reanimated"; +import { TextInputProps } from "react-native"; + +type ComposerWithSuggestionsEditProps = { + +} + + +function ComposerWithSuggestionsEdit( + { + value, + maxLines = -1, + onKeyPress = () => {}, + style, + numberOfLines: numberOfLinesProp = 0, + onSelectionChange = () => {}, + selection = { + start: 0, + end: 0, + }, + onBlur = () => {}, + onFocus = () => {}, + onChangeText = () => {}, + id = undefined + }: ComposerWithSuggestionsEditProps & ComposerProps, + ref: ForwardedRef>>, +) { + return ( + + ) +} + +export default React.forwardRef(ComposerWithSuggestionsEdit); \ No newline at end of file diff --git a/src/pages/home/report/ReportActionItemMessageEdit.tsx b/src/pages/home/report/ReportActionItemMessageEdit.tsx index 5934c4c333cb..a4d8d5da4889 100644 --- a/src/pages/home/report/ReportActionItemMessageEdit.tsx +++ b/src/pages/home/report/ReportActionItemMessageEdit.tsx @@ -38,6 +38,7 @@ import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type * as OnyxTypes from '@src/types/onyx'; import * as ReportActionContextMenu from './ContextMenu/ReportActionContextMenu'; +import ComposerWithSuggestionsEdit from './ReportActionCompose/ComposerWithSuggestionsEdit/ComposerWithSuggestionsEdit'; type ReportActionItemMessageEditProps = { /** All the data of the action */ @@ -410,7 +411,7 @@ function ReportActionItemMessageEdit( - { textInputRef.current = el; From dee95c99adb4d6529d71d3ea2c45748c9ccbfef9 Mon Sep 17 00:00:00 2001 From: dukenv0307 Date: Mon, 15 Jan 2024 12:29:56 +0700 Subject: [PATCH 02/24] implement suggestion for edit composer --- src/pages/home/ReportScreen.js | 1 + .../ComposerWithSuggestionsEdit.tsx | 93 +++++++++++++++---- src/pages/home/report/ReportActionItem.js | 1 + .../report/ReportActionItemMessageEdit.tsx | 55 ++++++++++- src/pages/home/report/ReportActionsList.js | 2 + .../report/ReportActionsListItemRenderer.js | 3 + src/pages/home/report/ReportActionsView.js | 1 + 7 files changed, 136 insertions(+), 20 deletions(-) diff --git a/src/pages/home/ReportScreen.js b/src/pages/home/ReportScreen.js index 64e48ecd5509..59804340547a 100644 --- a/src/pages/home/ReportScreen.js +++ b/src/pages/home/ReportScreen.js @@ -523,6 +523,7 @@ function ReportScreen({ isLoadingOlderReportActions={reportMetadata.isLoadingOlderReportActions} isComposerFullSize={isComposerFullSize} policy={policy} + listHeight={listHeight} /> )} diff --git a/src/pages/home/report/ReportActionCompose/ComposerWithSuggestionsEdit/ComposerWithSuggestionsEdit.tsx b/src/pages/home/report/ReportActionCompose/ComposerWithSuggestionsEdit/ComposerWithSuggestionsEdit.tsx index 2d742261419d..7acdb0c864ab 100644 --- a/src/pages/home/report/ReportActionCompose/ComposerWithSuggestionsEdit/ComposerWithSuggestionsEdit.tsx +++ b/src/pages/home/report/ReportActionCompose/ComposerWithSuggestionsEdit/ComposerWithSuggestionsEdit.tsx @@ -1,11 +1,25 @@ import { ComposerProps } from "@components/Composer/types"; import Composer from "@components/Composer"; -import React, { ForwardedRef } from 'react'; +import React, { Dispatch, ForwardedRef, MutableRefObject, SetStateAction, useRef, useState } from 'react'; import { AnimatedProps } from "react-native-reanimated"; import { TextInputProps } from "react-native"; +import Suggestions from '../Suggestions'; +import lodashGet from 'lodash/get'; +import * as SuggestionUtils from '@libs/SuggestionUtils'; +import useWindowDimensions from "@hooks/useWindowDimensions"; type ComposerWithSuggestionsEditProps = { - + setValue: Dispatch>, + setSelection: Dispatch>, + resetKeyboardInput: () => void, + isComposerFocused: boolean, + listHeight: number, + suggestionsRef: MutableRefObject, + updateDraft: (newValue: string) => void, + measureParentContainer: (callback: () => void) => void } @@ -24,25 +38,72 @@ function ComposerWithSuggestionsEdit( onBlur = () => {}, onFocus = () => {}, onChangeText = () => {}, + setValue = () => {}, + setSelection = () => {}, + resetKeyboardInput = () => {}, + isComposerFocused, + suggestionsRef, + listHeight, + updateDraft, + measureParentContainer, id = undefined }: ComposerWithSuggestionsEditProps & ComposerProps, ref: ForwardedRef>>, ) { + const {isSmallScreenWidth} = useWindowDimensions(); + + const suggestions = lodashGet(suggestionsRef, 'current.getSuggestions', () => [])(); + + const [composerHeight, setComposerHeight] = useState(0); + + const hasEnoughSpaceForLargeSuggestion = SuggestionUtils.hasEnoughSpaceForLargeSuggestionMenu(listHeight, composerHeight, suggestions.length); + + console.log(hasEnoughSpaceForLargeSuggestion); + + const isAutoSuggestionPickerLarge = !isSmallScreenWidth || (isSmallScreenWidth && hasEnoughSpaceForLargeSuggestion); + + return ( - + <> + { + const composerLayoutHeight = e.nativeEvent.layout.height; + if (composerHeight === composerLayoutHeight) { + return; + } + setComposerHeight(composerLayoutHeight); + }} + /> + + + + ) } diff --git a/src/pages/home/report/ReportActionItem.js b/src/pages/home/report/ReportActionItem.js index e490c4601d10..496e48ae3519 100644 --- a/src/pages/home/report/ReportActionItem.js +++ b/src/pages/home/report/ReportActionItem.js @@ -512,6 +512,7 @@ function ReportActionItem(props) { shouldDisableEmojiPicker={ (ReportUtils.chatIncludesConcierge(props.report) && User.isBlockedFromConcierge(props.blockedFromConcierge)) || ReportUtils.isArchivedRoom(props.report) } + listHeight={props.listHeight} /> )} diff --git a/src/pages/home/report/ReportActionItemMessageEdit.tsx b/src/pages/home/report/ReportActionItemMessageEdit.tsx index a4d8d5da4889..ba247fc4be6d 100644 --- a/src/pages/home/report/ReportActionItemMessageEdit.tsx +++ b/src/pages/home/report/ReportActionItemMessageEdit.tsx @@ -3,7 +3,7 @@ import Str from 'expensify-common/lib/str'; import lodashDebounce from 'lodash/debounce'; import type {ForwardedRef} from 'react'; import React, {forwardRef, useCallback, useEffect, useMemo, useRef, useState} from 'react'; -import {InteractionManager, Keyboard, View} from 'react-native'; +import {InteractionManager, Keyboard, NativeModules, View, findNodeHandle} from 'react-native'; import type {NativeSyntheticEvent, TextInput, TextInputFocusEventData, TextInputKeyPressEventData} from 'react-native'; import type {Emoji} from '@assets/emojis/types'; import Composer from '@components/Composer'; @@ -39,6 +39,11 @@ import ONYXKEYS from '@src/ONYXKEYS'; import type * as OnyxTypes from '@src/types/onyx'; import * as ReportActionContextMenu from './ContextMenu/ReportActionContextMenu'; import ComposerWithSuggestionsEdit from './ReportActionCompose/ComposerWithSuggestionsEdit/ComposerWithSuggestionsEdit'; +import convertToLTRForComposer from '@libs/convertToLTRForComposer'; +import getPlatform from '@libs/getPlatform'; + +const {RNTextInputReset} = NativeModules; + type ReportActionItemMessageEditProps = { /** All the data of the action */ @@ -62,6 +67,8 @@ type ReportActionItemMessageEditProps = { /** Stores user's preferred skin tone */ preferredSkinTone?: number; + + listHeight: number; }; // native ids @@ -69,9 +76,9 @@ const emojiButtonID = 'emojiButton'; const messageEditInput = 'messageEditInput'; const isMobileSafari = Browser.isMobileSafari(); - +const isIOSNative = getPlatform() === CONST.PLATFORM.IOS; function ReportActionItemMessageEdit( - {action, draftMessage, reportID, index, shouldDisableEmojiPicker = false, preferredSkinTone = CONST.EMOJI_DEFAULT_SKIN_TONE}: ReportActionItemMessageEditProps, + {action, draftMessage, reportID, index, shouldDisableEmojiPicker = false, preferredSkinTone = CONST.EMOJI_DEFAULT_SKIN_TONE, listHeight}: ReportActionItemMessageEditProps, forwardedRef: ForwardedRef, ) { const theme = useTheme(); @@ -124,6 +131,8 @@ function ReportActionItemMessageEdit( const isFocusedRef = useRef(false); const insertedEmojis = useRef([]); const draftRef = useRef(draft); + const suggestionsRef = useRef(); + const containerRef = useRef(null); useEffect(() => { if (ReportActionsUtils.isDeletedAction(action) || (action.message && draftMessage === action.message[0].html)) { @@ -253,6 +262,9 @@ function ReportActionItemMessageEdit( if (emojis?.length > 0) { const newEmojis = EmojiUtils.getAddedEmojis(emojis, emojisPresentBefore.current); if (newEmojis?.length > 0) { + if (suggestionsRef.current) { + suggestionsRef.current.resetSuggestions(); + } insertedEmojis.current = [...insertedEmojis.current, ...newEmojis]; debouncedUpdateFrequentlyUsedEmojis(); } @@ -354,6 +366,8 @@ function ReportActionItemMessageEdit( */ const triggerSaveOrCancel = useCallback( (e: NativeSyntheticEvent | KeyboardEvent) => { + suggestionsRef.current.triggerHotkeyActions(e); + if (!e || ComposerUtils.canSkipTriggerHotkeys(isSmallScreenWidth, isKeyboardShown)) { return; } @@ -369,6 +383,25 @@ function ReportActionItemMessageEdit( [deleteDraft, isKeyboardShown, isSmallScreenWidth, publishDraft], ); + const resetKeyboardInput = useCallback(() => { + if (!RNTextInputReset) { + return; + } + RNTextInputReset.resetKeyboardInput(findNodeHandle(textInputRef.current)); + }, [textInputRef]); + + const measureContainer = useCallback( + (callback) => { + if (!containerRef.current) { + return; + } + containerRef.current.measureInWindow(callback); + }, + // We added isComposerFullSize in dependencies so that when this value changes, we recalculate the position of the popup + // eslint-disable-next-line react-hooks/exhaustive-deps + [], + ); + /** * Focus the composer text input */ @@ -378,10 +411,13 @@ function ReportActionItemMessageEdit( validateCommentMaxLength(draft); }, [draft, validateCommentMaxLength]); + + return ( <> setSelection(e.nativeEvent.selection)} + onSelectionChange={(e) => { + suggestionsRef.current.onSelectionChange(e) + setSelection(e.nativeEvent.selection) + }} + setValue={setDraft} + setSelection={setSelection} + isComposerFocused={!!textInputRef.current && textInputRef.current.isFocused()} + resetKeyboardInput={resetKeyboardInput} + listHeight={listHeight} + suggestionsRef={suggestionsRef} + updateDraft={updateDraft} + measureParentContainer={measureContainer} /> diff --git a/src/pages/home/report/ReportActionsList.js b/src/pages/home/report/ReportActionsList.js index dba8ef2e11d0..4388e83ee728 100644 --- a/src/pages/home/report/ReportActionsList.js +++ b/src/pages/home/report/ReportActionsList.js @@ -136,6 +136,7 @@ function ReportActionsList({ loadOlderChats, onLayout, isComposerFullSize, + listHeight }) { const styles = useThemeStyles(); const {translate} = useLocalize(); @@ -417,6 +418,7 @@ function ReportActionsList({ mostRecentIOUReportActionID={mostRecentIOUReportActionID} shouldHideThreadDividerLine={shouldHideThreadDividerLine} shouldDisplayNewMarker={shouldDisplayNewMarker(reportAction, index)} + listHeight={listHeight} /> ), [report, linkedReportActionID, sortedReportActions, mostRecentIOUReportActionID, shouldHideThreadDividerLine, shouldDisplayNewMarker], diff --git a/src/pages/home/report/ReportActionsListItemRenderer.js b/src/pages/home/report/ReportActionsListItemRenderer.js index a9ae2b4c73b9..6ebbe22c76f0 100644 --- a/src/pages/home/report/ReportActionsListItemRenderer.js +++ b/src/pages/home/report/ReportActionsListItemRenderer.js @@ -49,6 +49,7 @@ function ReportActionsListItemRenderer({ shouldHideThreadDividerLine, shouldDisplayNewMarker, linkedReportActionID, + listHeight }) { const shouldDisplayParentAction = reportAction.actionName === CONST.REPORT.ACTIONS.TYPE.CREATED && @@ -62,6 +63,7 @@ function ReportActionsListItemRenderer({ parentReportID={`${report.parentReportID}`} shouldDisplayNewMarker={shouldDisplayNewMarker} index={index} + listHeight={listHeight} /> ) : ( ); } diff --git a/src/pages/home/report/ReportActionsView.js b/src/pages/home/report/ReportActionsView.js index 2758437a3962..490b86ada468 100755 --- a/src/pages/home/report/ReportActionsView.js +++ b/src/pages/home/report/ReportActionsView.js @@ -264,6 +264,7 @@ function ReportActionsView(props) { isLoadingOlderReportActions={props.isLoadingOlderReportActions} isLoadingNewerReportActions={props.isLoadingNewerReportActions} policy={props.policy} + listHeight={props.listHeight} /> From 0de268694777a393959bc20e11456be409ab7deb Mon Sep 17 00:00:00 2001 From: dukenv0307 Date: Thu, 25 Jan 2024 17:56:15 +0700 Subject: [PATCH 03/24] implement suggestion for edit composer --- src/components/Composer/types.ts | 4 ++- src/pages/home/ReportScreen.js | 1 - .../ComposerWithSuggestionsEdit.tsx | 36 ++++++------------- .../home/report/ReportActionCompose/types.ts | 14 ++++++++ src/pages/home/report/ReportActionItem.js | 1 - .../report/ReportActionItemMessageEdit.tsx | 24 ++++++------- src/pages/home/report/ReportActionsList.js | 2 -- .../report/ReportActionsListItemRenderer.js | 3 -- src/pages/home/report/ReportActionsView.js | 1 - 9 files changed, 38 insertions(+), 48 deletions(-) create mode 100644 src/pages/home/report/ReportActionCompose/types.ts diff --git a/src/components/Composer/types.ts b/src/components/Composer/types.ts index d8d88970ea78..6d4689ab8150 100644 --- a/src/components/Composer/types.ts +++ b/src/components/Composer/types.ts @@ -1,4 +1,4 @@ -import type {NativeSyntheticEvent, StyleProp, TextInputFocusEventData, TextInputKeyPressEventData, TextInputSelectionChangeEventData, TextStyle} from 'react-native'; +import type {LayoutChangeEvent, NativeSyntheticEvent, StyleProp, TextInputFocusEventData, TextInputKeyPressEventData, TextInputSelectionChangeEventData, TextStyle} from 'react-native'; type TextSelection = { start: number; @@ -80,6 +80,8 @@ type ComposerProps = { onBlur?: (event: NativeSyntheticEvent) => void; + onLayout?: (event: LayoutChangeEvent) => void; + /** Should make the input only scroll inside the element avoid scroll out to parent */ shouldContainScroll?: boolean; }; diff --git a/src/pages/home/ReportScreen.js b/src/pages/home/ReportScreen.js index e5df63f0ff04..4d043f12351e 100644 --- a/src/pages/home/ReportScreen.js +++ b/src/pages/home/ReportScreen.js @@ -528,7 +528,6 @@ function ReportScreen({ isLoadingOlderReportActions={reportMetadata.isLoadingOlderReportActions} isComposerFullSize={isComposerFullSize} policy={policy} - listHeight={listHeight} /> )} diff --git a/src/pages/home/report/ReportActionCompose/ComposerWithSuggestionsEdit/ComposerWithSuggestionsEdit.tsx b/src/pages/home/report/ReportActionCompose/ComposerWithSuggestionsEdit/ComposerWithSuggestionsEdit.tsx index 7acdb0c864ab..efc1370f7e23 100644 --- a/src/pages/home/report/ReportActionCompose/ComposerWithSuggestionsEdit/ComposerWithSuggestionsEdit.tsx +++ b/src/pages/home/report/ReportActionCompose/ComposerWithSuggestionsEdit/ComposerWithSuggestionsEdit.tsx @@ -1,12 +1,11 @@ -import { ComposerProps } from "@components/Composer/types"; +import type { ComposerProps } from "@components/Composer/types"; import Composer from "@components/Composer"; -import React, { Dispatch, ForwardedRef, MutableRefObject, SetStateAction, useRef, useState } from 'react'; -import { AnimatedProps } from "react-native-reanimated"; -import { TextInputProps } from "react-native"; -import Suggestions from '../Suggestions'; -import lodashGet from 'lodash/get'; -import * as SuggestionUtils from '@libs/SuggestionUtils'; -import useWindowDimensions from "@hooks/useWindowDimensions"; +import type { Dispatch, ForwardedRef, MutableRefObject, SetStateAction} from 'react'; +import React, { useState } from 'react'; +import type { AnimatedProps } from "react-native-reanimated"; +import type {TextInputProps} from "react-native"; +import Suggestions from '@pages/home/report/ReportActionCompose/Suggestions'; +import type { SuggestionsRef } from "@pages/home/report/ReportActionCompose/types"; type ComposerWithSuggestionsEditProps = { setValue: Dispatch>, @@ -16,8 +15,7 @@ type ComposerWithSuggestionsEditProps = { }>>, resetKeyboardInput: () => void, isComposerFocused: boolean, - listHeight: number, - suggestionsRef: MutableRefObject, + suggestionsRef: MutableRefObject, updateDraft: (newValue: string) => void, measureParentContainer: (callback: () => void) => void } @@ -29,7 +27,6 @@ function ComposerWithSuggestionsEdit( maxLines = -1, onKeyPress = () => {}, style, - numberOfLines: numberOfLinesProp = 0, onSelectionChange = () => {}, selection = { start: 0, @@ -43,26 +40,14 @@ function ComposerWithSuggestionsEdit( resetKeyboardInput = () => {}, isComposerFocused, suggestionsRef, - listHeight, updateDraft, measureParentContainer, id = undefined }: ComposerWithSuggestionsEditProps & ComposerProps, ref: ForwardedRef>>, ) { - const {isSmallScreenWidth} = useWindowDimensions(); - - const suggestions = lodashGet(suggestionsRef, 'current.getSuggestions', () => [])(); - const [composerHeight, setComposerHeight] = useState(0); - const hasEnoughSpaceForLargeSuggestion = SuggestionUtils.hasEnoughSpaceForLargeSuggestionMenu(listHeight, composerHeight, suggestions.length); - - console.log(hasEnoughSpaceForLargeSuggestion); - - const isAutoSuggestionPickerLarge = !isSmallScreenWidth || (isSmallScreenWidth && hasEnoughSpaceForLargeSuggestion); - - return ( <> - ) } diff --git a/src/pages/home/report/ReportActionCompose/types.ts b/src/pages/home/report/ReportActionCompose/types.ts new file mode 100644 index 000000000000..8630189d45ec --- /dev/null +++ b/src/pages/home/report/ReportActionCompose/types.ts @@ -0,0 +1,14 @@ +import type { NativeSyntheticEvent, TextInputKeyPressEventData, TextInputSelectionChangeEventData } from "react-native"; + +type SuggestionsRef = { + getSuggestions: () => void; + resetSuggestions: () => void; + triggerHotkeyActions: (event: NativeSyntheticEvent | KeyboardEvent) => void; + onSelectionChange: (event: NativeSyntheticEvent) => void; + updateShouldShowSuggestionMenuToFalse: () => void; + setShouldBlockSuggestionCalc: () => void; +} + + +// eslint-disable-next-line import/prefer-default-export +export type {SuggestionsRef}; \ No newline at end of file diff --git a/src/pages/home/report/ReportActionItem.js b/src/pages/home/report/ReportActionItem.js index f3a02cae06dd..80fb341a2cf8 100644 --- a/src/pages/home/report/ReportActionItem.js +++ b/src/pages/home/report/ReportActionItem.js @@ -514,7 +514,6 @@ function ReportActionItem(props) { shouldDisableEmojiPicker={ (ReportUtils.chatIncludesConcierge(props.report) && User.isBlockedFromConcierge(props.blockedFromConcierge)) || ReportUtils.isArchivedRoom(props.report) } - listHeight={props.listHeight} /> )} diff --git a/src/pages/home/report/ReportActionItemMessageEdit.tsx b/src/pages/home/report/ReportActionItemMessageEdit.tsx index ba247fc4be6d..7d13bd6335ca 100644 --- a/src/pages/home/report/ReportActionItemMessageEdit.tsx +++ b/src/pages/home/report/ReportActionItemMessageEdit.tsx @@ -6,7 +6,6 @@ import React, {forwardRef, useCallback, useEffect, useMemo, useRef, useState} fr import {InteractionManager, Keyboard, NativeModules, View, findNodeHandle} from 'react-native'; import type {NativeSyntheticEvent, TextInput, TextInputFocusEventData, TextInputKeyPressEventData} from 'react-native'; import type {Emoji} from '@assets/emojis/types'; -import Composer from '@components/Composer'; import EmojiPickerButton from '@components/EmojiPicker/EmojiPickerButton'; import ExceededCommentLength from '@components/ExceededCommentLength'; import Icon from '@components/Icon'; @@ -39,8 +38,7 @@ import ONYXKEYS from '@src/ONYXKEYS'; import type * as OnyxTypes from '@src/types/onyx'; import * as ReportActionContextMenu from './ContextMenu/ReportActionContextMenu'; import ComposerWithSuggestionsEdit from './ReportActionCompose/ComposerWithSuggestionsEdit/ComposerWithSuggestionsEdit'; -import convertToLTRForComposer from '@libs/convertToLTRForComposer'; -import getPlatform from '@libs/getPlatform'; +import type { SuggestionsRef } from './ReportActionCompose/types'; const {RNTextInputReset} = NativeModules; @@ -67,8 +65,6 @@ type ReportActionItemMessageEditProps = { /** Stores user's preferred skin tone */ preferredSkinTone?: number; - - listHeight: number; }; // native ids @@ -76,9 +72,8 @@ const emojiButtonID = 'emojiButton'; const messageEditInput = 'messageEditInput'; const isMobileSafari = Browser.isMobileSafari(); -const isIOSNative = getPlatform() === CONST.PLATFORM.IOS; function ReportActionItemMessageEdit( - {action, draftMessage, reportID, index, shouldDisableEmojiPicker = false, preferredSkinTone = CONST.EMOJI_DEFAULT_SKIN_TONE, listHeight}: ReportActionItemMessageEditProps, + {action, draftMessage, reportID, index, shouldDisableEmojiPicker = false, preferredSkinTone = CONST.EMOJI_DEFAULT_SKIN_TONE}: ReportActionItemMessageEditProps, forwardedRef: ForwardedRef, ) { const theme = useTheme(); @@ -131,8 +126,8 @@ function ReportActionItemMessageEdit( const isFocusedRef = useRef(false); const insertedEmojis = useRef([]); const draftRef = useRef(draft); - const suggestionsRef = useRef(); - const containerRef = useRef(null); + const suggestionsRef = useRef(); + const containerRef = useRef(null); useEffect(() => { if (ReportActionsUtils.isDeletedAction(action) || (action.message && draftMessage === action.message[0].html)) { @@ -366,7 +361,9 @@ function ReportActionItemMessageEdit( */ const triggerSaveOrCancel = useCallback( (e: NativeSyntheticEvent | KeyboardEvent) => { - suggestionsRef.current.triggerHotkeyActions(e); + if (suggestionsRef.current) { + suggestionsRef.current.triggerHotkeyActions(e); + } if (!e || ComposerUtils.canSkipTriggerHotkeys(isSmallScreenWidth, isKeyboardShown)) { return; @@ -391,7 +388,7 @@ function ReportActionItemMessageEdit( }, [textInputRef]); const measureContainer = useCallback( - (callback) => { + (callback: () => void) => { if (!containerRef.current) { return; } @@ -488,14 +485,15 @@ function ReportActionItemMessageEdit( }} selection={selection} onSelectionChange={(e) => { - suggestionsRef.current.onSelectionChange(e) + if (suggestionsRef.current) { + suggestionsRef.current.onSelectionChange(e) + } setSelection(e.nativeEvent.selection) }} setValue={setDraft} setSelection={setSelection} isComposerFocused={!!textInputRef.current && textInputRef.current.isFocused()} resetKeyboardInput={resetKeyboardInput} - listHeight={listHeight} suggestionsRef={suggestionsRef} updateDraft={updateDraft} measureParentContainer={measureContainer} diff --git a/src/pages/home/report/ReportActionsList.js b/src/pages/home/report/ReportActionsList.js index 322d9277af3f..ce8dcb10ef5f 100644 --- a/src/pages/home/report/ReportActionsList.js +++ b/src/pages/home/report/ReportActionsList.js @@ -136,7 +136,6 @@ function ReportActionsList({ loadOlderChats, onLayout, isComposerFullSize, - listHeight }) { const styles = useThemeStyles(); const {translate} = useLocalize(); @@ -461,7 +460,6 @@ function ReportActionsList({ mostRecentIOUReportActionID={mostRecentIOUReportActionID} shouldHideThreadDividerLine={shouldHideThreadDividerLine} shouldDisplayNewMarker={shouldDisplayNewMarker(reportAction, index)} - listHeight={listHeight} /> ), [report, linkedReportActionID, sortedReportActions, mostRecentIOUReportActionID, shouldHideThreadDividerLine, shouldDisplayNewMarker], diff --git a/src/pages/home/report/ReportActionsListItemRenderer.js b/src/pages/home/report/ReportActionsListItemRenderer.js index 6ebbe22c76f0..a9ae2b4c73b9 100644 --- a/src/pages/home/report/ReportActionsListItemRenderer.js +++ b/src/pages/home/report/ReportActionsListItemRenderer.js @@ -49,7 +49,6 @@ function ReportActionsListItemRenderer({ shouldHideThreadDividerLine, shouldDisplayNewMarker, linkedReportActionID, - listHeight }) { const shouldDisplayParentAction = reportAction.actionName === CONST.REPORT.ACTIONS.TYPE.CREATED && @@ -63,7 +62,6 @@ function ReportActionsListItemRenderer({ parentReportID={`${report.parentReportID}`} shouldDisplayNewMarker={shouldDisplayNewMarker} index={index} - listHeight={listHeight} /> ) : ( ); } diff --git a/src/pages/home/report/ReportActionsView.js b/src/pages/home/report/ReportActionsView.js index 12179b211e6c..4843a29dde60 100755 --- a/src/pages/home/report/ReportActionsView.js +++ b/src/pages/home/report/ReportActionsView.js @@ -263,7 +263,6 @@ function ReportActionsView(props) { isLoadingOlderReportActions={props.isLoadingOlderReportActions} isLoadingNewerReportActions={props.isLoadingNewerReportActions} policy={props.policy} - listHeight={props.listHeight} /> From 02732408714a4be850731bb3eb86f44960d58dbf Mon Sep 17 00:00:00 2001 From: dukenv0307 Date: Fri, 26 Jan 2024 15:51:50 +0700 Subject: [PATCH 04/24] display suggestion below when composer at the top of the screen --- .../AutoCompleteSuggestions/index.tsx | 9 +++++-- .../AutoCompleteSuggestions/types.ts | 2 +- src/libs/SuggestionUtils.ts | 24 ++++++++++++++++++- src/styles/utils/index.ts | 3 ++- 4 files changed, 33 insertions(+), 5 deletions(-) diff --git a/src/components/AutoCompleteSuggestions/index.tsx b/src/components/AutoCompleteSuggestions/index.tsx index baca4011a177..01aa6ea1fc17 100644 --- a/src/components/AutoCompleteSuggestions/index.tsx +++ b/src/components/AutoCompleteSuggestions/index.tsx @@ -6,6 +6,7 @@ import useWindowDimensions from '@hooks/useWindowDimensions'; import * as DeviceCapabilities from '@libs/DeviceCapabilities'; import BaseAutoCompleteSuggestions from './BaseAutoCompleteSuggestions'; import type {AutoCompleteSuggestionsProps} from './types'; +import { measureHeightOfSuggestioContainer } from '@libs/SuggestionUtils'; /** * On the mobile-web platform, when long-pressing on auto-complete suggestions, @@ -18,6 +19,7 @@ function AutoCompleteSuggestions({measureParentContainer = () => {} const StyleUtils = useStyleUtils(); const containerRef = React.useRef(null); const {windowHeight, windowWidth} = useWindowDimensions(); + const suggestionContainerHeight = measureHeightOfSuggestioContainer(props.suggestions.length, props.isSuggestionPickerLarge); const [{width, left, bottom}, setContainerState] = React.useState({ width: 0, left: 0, @@ -41,9 +43,12 @@ function AutoCompleteSuggestions({measureParentContainer = () => {} if (!measureParentContainer) { return; } - measureParentContainer((x, y, w) => setContainerState({left: x, bottom: windowHeight - y, width: w})); - }, [measureParentContainer, windowHeight, windowWidth]); + measureParentContainer((x, y, w, h) => { + const currenBottom = y < suggestionContainerHeight ? windowHeight - y - suggestionContainerHeight - h : windowHeight - y; + setContainerState({left: x, bottom: currenBottom, width: w}) + }); + }, [measureParentContainer, windowHeight, windowWidth]); const componentToRender = ( // eslint-disable-next-line react/jsx-props-no-spreading diff --git a/src/components/AutoCompleteSuggestions/types.ts b/src/components/AutoCompleteSuggestions/types.ts index 61d614dcf2e4..20477af2e365 100644 --- a/src/components/AutoCompleteSuggestions/types.ts +++ b/src/components/AutoCompleteSuggestions/types.ts @@ -1,6 +1,6 @@ import type {ReactElement} from 'react'; -type MeasureParentContainerCallback = (x: number, y: number, width: number) => void; +type MeasureParentContainerCallback = (x: number, y: number, width: number, height: number) => void; type RenderSuggestionMenuItemProps = { item: TSuggestion; diff --git a/src/libs/SuggestionUtils.ts b/src/libs/SuggestionUtils.ts index 96379ce49ef3..e920678b0ecc 100644 --- a/src/libs/SuggestionUtils.ts +++ b/src/libs/SuggestionUtils.ts @@ -20,4 +20,26 @@ function hasEnoughSpaceForLargeSuggestionMenu(listHeight: number, composerHeight return availableHeight > menuHeight; } -export {trimLeadingSpace, hasEnoughSpaceForLargeSuggestionMenu}; +const measureHeightOfSuggestioContainer = (numRows: number, isSuggestionPickerLarge: boolean): number => { + const borderAndPadding = CONST.AUTO_COMPLETE_SUGGESTER.SUGGESTER_PADDING + 2; + let suggestionHeight = 0; + + if (isSuggestionPickerLarge) { + if (numRows > CONST.AUTO_COMPLETE_SUGGESTER.MAX_AMOUNT_OF_VISIBLE_SUGGESTIONS_IN_CONTAINER) { + // On large screens, if there are more than 5 suggestions, we display a scrollable window with a height of 5 items, indicating that there are more items available + suggestionHeight = CONST.AUTO_COMPLETE_SUGGESTER.MAX_AMOUNT_OF_VISIBLE_SUGGESTIONS_IN_CONTAINER * CONST.AUTO_COMPLETE_SUGGESTER.SUGGESTION_ROW_HEIGHT; + } else { + suggestionHeight = numRows * CONST.AUTO_COMPLETE_SUGGESTER.SUGGESTION_ROW_HEIGHT; + } + } else { + if (numRows > 2) { + // On small screens, we display a scrollable window with a height of 2.5 items, indicating that there are more items available beyond what is currently visible + suggestionHeight = CONST.AUTO_COMPLETE_SUGGESTER.SMALL_CONTAINER_HEIGHT_FACTOR * CONST.AUTO_COMPLETE_SUGGESTER.SUGGESTION_ROW_HEIGHT; + } else { + suggestionHeight = numRows * CONST.AUTO_COMPLETE_SUGGESTER.SUGGESTION_ROW_HEIGHT; + } + } + return suggestionHeight + borderAndPadding; +}; + +export {trimLeadingSpace, hasEnoughSpaceForLargeSuggestionMenu, measureHeightOfSuggestioContainer}; diff --git a/src/styles/utils/index.ts b/src/styles/utils/index.ts index cdefc8bc1c91..289e3430ed40 100644 --- a/src/styles/utils/index.ts +++ b/src/styles/utils/index.ts @@ -782,7 +782,7 @@ type GetBaseAutoCompleteSuggestionContainerStyleParams = { */ function getBaseAutoCompleteSuggestionContainerStyle({left, bottom, width}: GetBaseAutoCompleteSuggestionContainerStyleParams): ViewStyle { return { - ...positioning.pFixed, + // ...positioning.pFixed, bottom, left, width, @@ -797,6 +797,7 @@ function getAutoCompleteSuggestionContainerStyle(itemsHeight: number): ViewStyle const borderWidth = 2; const height = itemsHeight + 2 * CONST.AUTO_COMPLETE_SUGGESTER.SUGGESTER_INNER_PADDING; + console.log(height); // The suggester is positioned absolutely within the component that includes the input and RecipientLocalTime view (for non-expanded mode only). To position it correctly, // we need to shift it by the suggester's height plus its padding and, if applicable, the height of the RecipientLocalTime view. From 31bf412c900678781408b37185939d8bf86de4c1 Mon Sep 17 00:00:00 2001 From: dukenv0307 Date: Fri, 26 Jan 2024 16:52:11 +0700 Subject: [PATCH 05/24] fix edge case --- .../AutoCompleteSuggestions/BaseAutoCompleteSuggestions.tsx | 3 ++- src/components/AutoCompleteSuggestions/index.tsx | 3 +++ src/components/AutoCompleteSuggestions/types.ts | 3 +++ src/styles/utils/index.ts | 5 ++--- 4 files changed, 10 insertions(+), 4 deletions(-) diff --git a/src/components/AutoCompleteSuggestions/BaseAutoCompleteSuggestions.tsx b/src/components/AutoCompleteSuggestions/BaseAutoCompleteSuggestions.tsx index 5da9c6981603..f5e6cdc52574 100644 --- a/src/components/AutoCompleteSuggestions/BaseAutoCompleteSuggestions.tsx +++ b/src/components/AutoCompleteSuggestions/BaseAutoCompleteSuggestions.tsx @@ -39,6 +39,7 @@ function BaseAutoCompleteSuggestions( suggestions, isSuggestionPickerLarge, keyExtractor, + shouldBelowParentContainer = false }: AutoCompleteSuggestionsProps, ref: ForwardedRef, ) { @@ -67,7 +68,7 @@ function BaseAutoCompleteSuggestions( ); const innerHeight = CONST.AUTO_COMPLETE_SUGGESTER.SUGGESTION_ROW_HEIGHT * suggestions.length; - const animatedStyles = useAnimatedStyle(() => StyleUtils.getAutoCompleteSuggestionContainerStyle(rowHeight.value)); + const animatedStyles = useAnimatedStyle(() => StyleUtils.getAutoCompleteSuggestionContainerStyle(rowHeight.value, shouldBelowParentContainer)); const estimatedListSize = useMemo( () => ({ height: CONST.AUTO_COMPLETE_SUGGESTER.SUGGESTION_ROW_HEIGHT * suggestions.length, diff --git a/src/components/AutoCompleteSuggestions/index.tsx b/src/components/AutoCompleteSuggestions/index.tsx index 01aa6ea1fc17..c6911366f102 100644 --- a/src/components/AutoCompleteSuggestions/index.tsx +++ b/src/components/AutoCompleteSuggestions/index.tsx @@ -25,6 +25,7 @@ function AutoCompleteSuggestions({measureParentContainer = () => {} left: 0, bottom: 0, }); + const [shouldBelowContainer, setShouldBelowContainer] = React.useState(false); React.useEffect(() => { const container = containerRef.current; if (!container) { @@ -46,6 +47,7 @@ function AutoCompleteSuggestions({measureParentContainer = () => {} measureParentContainer((x, y, w, h) => { const currenBottom = y < suggestionContainerHeight ? windowHeight - y - suggestionContainerHeight - h : windowHeight - y; + setShouldBelowContainer(y < suggestionContainerHeight); setContainerState({left: x, bottom: currenBottom, width: w}) }); }, [measureParentContainer, windowHeight, windowWidth]); @@ -53,6 +55,7 @@ function AutoCompleteSuggestions({measureParentContainer = () => {} // eslint-disable-next-line react/jsx-props-no-spreading {...props} + shouldBelowParentContainer={shouldBelowContainer} ref={containerRef} /> ); diff --git a/src/components/AutoCompleteSuggestions/types.ts b/src/components/AutoCompleteSuggestions/types.ts index 20477af2e365..2f6f07d51be1 100644 --- a/src/components/AutoCompleteSuggestions/types.ts +++ b/src/components/AutoCompleteSuggestions/types.ts @@ -33,6 +33,9 @@ type AutoCompleteSuggestionsProps = { /** Meaures the parent container's position and dimensions. */ measureParentContainer?: (callback: MeasureParentContainerCallback) => void; + + /** Whether suggestion should be displayed below the parent container or not */ + shouldBelowParentContainer?: boolean }; export type {AutoCompleteSuggestionsProps, RenderSuggestionMenuItemProps}; diff --git a/src/styles/utils/index.ts b/src/styles/utils/index.ts index 289e3430ed40..d8a616fa34c2 100644 --- a/src/styles/utils/index.ts +++ b/src/styles/utils/index.ts @@ -792,18 +792,17 @@ function getBaseAutoCompleteSuggestionContainerStyle({left, bottom, width}: GetB /** * Gets the correct position for auto complete suggestion container */ -function getAutoCompleteSuggestionContainerStyle(itemsHeight: number): ViewStyle { +function getAutoCompleteSuggestionContainerStyle(itemsHeight: number, shouldBelowParentContainer: boolean): ViewStyle { 'worklet'; const borderWidth = 2; const height = itemsHeight + 2 * CONST.AUTO_COMPLETE_SUGGESTER.SUGGESTER_INNER_PADDING; - console.log(height); // The suggester is positioned absolutely within the component that includes the input and RecipientLocalTime view (for non-expanded mode only). To position it correctly, // we need to shift it by the suggester's height plus its padding and, if applicable, the height of the RecipientLocalTime view. return { overflow: 'hidden', - top: -(height + CONST.AUTO_COMPLETE_SUGGESTER.SUGGESTER_PADDING + borderWidth), + top: -(height + (shouldBelowParentContainer ? -2 : 1) * (CONST.AUTO_COMPLETE_SUGGESTER.SUGGESTER_PADDING + borderWidth)), height, minHeight: CONST.AUTO_COMPLETE_SUGGESTER.SUGGESTION_ROW_HEIGHT, }; From ee43388d477ef88da7414c15c69ffb1b34221325 Mon Sep 17 00:00:00 2001 From: dukenv0307 Date: Mon, 29 Jan 2024 17:31:42 +0700 Subject: [PATCH 06/24] create global ref --- .../BaseAutoCompleteSuggestions.tsx | 2 +- .../AutoCompleteSuggestions/index.tsx | 7 +-- .../AutoCompleteSuggestions/types.ts | 2 +- src/components/EmojiSuggestions.tsx | 2 +- src/libs/SuggestionUtils.ts | 10 ++-- src/libs/actions/SuggestionsAction.ts | 50 +++++++++++++++++++ .../ComposerWithSuggestionsEdit.tsx | 45 +++++++++-------- .../home/report/ReportActionCompose/types.ts | 14 ------ .../report/ReportActionItemMessageEdit.tsx | 28 ++++------- src/pages/home/report/ReportActionsView.js | 2 + 10 files changed, 97 insertions(+), 65 deletions(-) create mode 100644 src/libs/actions/SuggestionsAction.ts delete mode 100644 src/pages/home/report/ReportActionCompose/types.ts diff --git a/src/components/AutoCompleteSuggestions/BaseAutoCompleteSuggestions.tsx b/src/components/AutoCompleteSuggestions/BaseAutoCompleteSuggestions.tsx index f5e6cdc52574..2b7750f732f2 100644 --- a/src/components/AutoCompleteSuggestions/BaseAutoCompleteSuggestions.tsx +++ b/src/components/AutoCompleteSuggestions/BaseAutoCompleteSuggestions.tsx @@ -39,7 +39,7 @@ function BaseAutoCompleteSuggestions( suggestions, isSuggestionPickerLarge, keyExtractor, - shouldBelowParentContainer = false + shouldBelowParentContainer = false, }: AutoCompleteSuggestionsProps, ref: ForwardedRef, ) { diff --git a/src/components/AutoCompleteSuggestions/index.tsx b/src/components/AutoCompleteSuggestions/index.tsx index c6911366f102..bda8e0aa7711 100644 --- a/src/components/AutoCompleteSuggestions/index.tsx +++ b/src/components/AutoCompleteSuggestions/index.tsx @@ -4,9 +4,9 @@ import {View} from 'react-native'; import useStyleUtils from '@hooks/useStyleUtils'; import useWindowDimensions from '@hooks/useWindowDimensions'; import * as DeviceCapabilities from '@libs/DeviceCapabilities'; +import {measureHeightOfSuggestioContainer} from '@libs/SuggestionUtils'; import BaseAutoCompleteSuggestions from './BaseAutoCompleteSuggestions'; import type {AutoCompleteSuggestionsProps} from './types'; -import { measureHeightOfSuggestioContainer } from '@libs/SuggestionUtils'; /** * On the mobile-web platform, when long-pressing on auto-complete suggestions, @@ -48,9 +48,10 @@ function AutoCompleteSuggestions({measureParentContainer = () => {} measureParentContainer((x, y, w, h) => { const currenBottom = y < suggestionContainerHeight ? windowHeight - y - suggestionContainerHeight - h : windowHeight - y; setShouldBelowContainer(y < suggestionContainerHeight); - setContainerState({left: x, bottom: currenBottom, width: w}) + setContainerState({left: x, bottom: currenBottom, width: w}); }); - }, [measureParentContainer, windowHeight, windowWidth]); + }, [measureParentContainer, windowHeight, windowWidth, suggestionContainerHeight]); + const componentToRender = ( // eslint-disable-next-line react/jsx-props-no-spreading diff --git a/src/components/AutoCompleteSuggestions/types.ts b/src/components/AutoCompleteSuggestions/types.ts index 2f6f07d51be1..b837901026b2 100644 --- a/src/components/AutoCompleteSuggestions/types.ts +++ b/src/components/AutoCompleteSuggestions/types.ts @@ -35,7 +35,7 @@ type AutoCompleteSuggestionsProps = { measureParentContainer?: (callback: MeasureParentContainerCallback) => void; /** Whether suggestion should be displayed below the parent container or not */ - shouldBelowParentContainer?: boolean + shouldBelowParentContainer?: boolean; }; export type {AutoCompleteSuggestionsProps, RenderSuggestionMenuItemProps}; diff --git a/src/components/EmojiSuggestions.tsx b/src/components/EmojiSuggestions.tsx index 1c0306741048..c95288f43164 100644 --- a/src/components/EmojiSuggestions.tsx +++ b/src/components/EmojiSuggestions.tsx @@ -9,7 +9,7 @@ import getStyledTextArray from '@libs/GetStyledTextArray'; import AutoCompleteSuggestions from './AutoCompleteSuggestions'; import Text from './Text'; -type MeasureParentContainerCallback = (x: number, y: number, width: number) => void; +type MeasureParentContainerCallback = (x: number, y: number, width: number, height: number) => void; type EmojiSuggestionsProps = { /** The index of the highlighted emoji */ diff --git a/src/libs/SuggestionUtils.ts b/src/libs/SuggestionUtils.ts index e920678b0ecc..00039592ba77 100644 --- a/src/libs/SuggestionUtils.ts +++ b/src/libs/SuggestionUtils.ts @@ -31,13 +31,11 @@ const measureHeightOfSuggestioContainer = (numRows: number, isSuggestionPickerLa } else { suggestionHeight = numRows * CONST.AUTO_COMPLETE_SUGGESTER.SUGGESTION_ROW_HEIGHT; } + } else if (numRows > 2) { + // On small screens, we display a scrollable window with a height of 2.5 items, indicating that there are more items available beyond what is currently visible + suggestionHeight = CONST.AUTO_COMPLETE_SUGGESTER.SMALL_CONTAINER_HEIGHT_FACTOR * CONST.AUTO_COMPLETE_SUGGESTER.SUGGESTION_ROW_HEIGHT; } else { - if (numRows > 2) { - // On small screens, we display a scrollable window with a height of 2.5 items, indicating that there are more items available beyond what is currently visible - suggestionHeight = CONST.AUTO_COMPLETE_SUGGESTER.SMALL_CONTAINER_HEIGHT_FACTOR * CONST.AUTO_COMPLETE_SUGGESTER.SUGGESTION_ROW_HEIGHT; - } else { - suggestionHeight = numRows * CONST.AUTO_COMPLETE_SUGGESTER.SUGGESTION_ROW_HEIGHT; - } + suggestionHeight = numRows * CONST.AUTO_COMPLETE_SUGGESTER.SUGGESTION_ROW_HEIGHT; } return suggestionHeight + borderAndPadding; }; diff --git a/src/libs/actions/SuggestionsAction.ts b/src/libs/actions/SuggestionsAction.ts new file mode 100644 index 000000000000..9fecd18f7d10 --- /dev/null +++ b/src/libs/actions/SuggestionsAction.ts @@ -0,0 +1,50 @@ +import React from 'react'; +import type {NativeSyntheticEvent, TextInputKeyPressEventData, TextInputSelectionChangeEventData} from 'react-native'; + +type SuggestionsRef = { + getSuggestions: () => void; + resetSuggestions: () => void; + triggerHotkeyActions: (event: NativeSyntheticEvent | KeyboardEvent) => boolean; + onSelectionChange: (event: NativeSyntheticEvent) => void; + updateShouldShowSuggestionMenuToFalse: () => void; + setShouldBlockSuggestionCalc: () => void; +}; + +const suggestionsRef = React.createRef(); + +function resetSuggestions() { + if (!suggestionsRef.current) { + return; + } + + suggestionsRef.current.resetSuggestions(); +} + +function triggerHotkeyActions(event: NativeSyntheticEvent | KeyboardEvent): boolean { + if (!suggestionsRef.current) { + return false; + } + + return suggestionsRef.current.triggerHotkeyActions(event); +} + +function updateShouldShowSuggestionMenuToFalse() { + if (!suggestionsRef.current) { + return; + } + + suggestionsRef.current.updateShouldShowSuggestionMenuToFalse(); +} + +function onSelectionChange(event: NativeSyntheticEvent) { + if (!suggestionsRef.current) { + return; + } + + suggestionsRef.current.onSelectionChange(event); +} + +export {suggestionsRef, resetSuggestions, triggerHotkeyActions, onSelectionChange, updateShouldShowSuggestionMenuToFalse}; + +// eslint-disable-next-line import/prefer-default-export +export type {SuggestionsRef}; diff --git a/src/pages/home/report/ReportActionCompose/ComposerWithSuggestionsEdit/ComposerWithSuggestionsEdit.tsx b/src/pages/home/report/ReportActionCompose/ComposerWithSuggestionsEdit/ComposerWithSuggestionsEdit.tsx index efc1370f7e23..927d160a8b7f 100644 --- a/src/pages/home/report/ReportActionCompose/ComposerWithSuggestionsEdit/ComposerWithSuggestionsEdit.tsx +++ b/src/pages/home/report/ReportActionCompose/ComposerWithSuggestionsEdit/ComposerWithSuggestionsEdit.tsx @@ -1,25 +1,26 @@ -import type { ComposerProps } from "@components/Composer/types"; -import Composer from "@components/Composer"; -import type { Dispatch, ForwardedRef, MutableRefObject, SetStateAction} from 'react'; -import React, { useState } from 'react'; -import type { AnimatedProps } from "react-native-reanimated"; -import type {TextInputProps} from "react-native"; +import type {Dispatch, ForwardedRef, RefObject, SetStateAction} from 'react'; +import React, {useState} from 'react'; +import type {TextInputProps} from 'react-native'; +import type {AnimatedProps} from 'react-native-reanimated'; +import Composer from '@components/Composer'; +import type {ComposerProps} from '@components/Composer/types'; +import type {SuggestionsRef} from '@libs/actions/SuggestionsAction'; import Suggestions from '@pages/home/report/ReportActionCompose/Suggestions'; -import type { SuggestionsRef } from "@pages/home/report/ReportActionCompose/types"; type ComposerWithSuggestionsEditProps = { - setValue: Dispatch>, - setSelection: Dispatch>, - resetKeyboardInput: () => void, - isComposerFocused: boolean, - suggestionsRef: MutableRefObject, - updateDraft: (newValue: string) => void, - measureParentContainer: (callback: () => void) => void -} - + setValue: Dispatch>; + setSelection: Dispatch< + SetStateAction<{ + start: number; + end: number; + }> + >; + resetKeyboardInput: () => void; + isComposerFocused: boolean; + suggestionsRef: RefObject; + updateDraft: (newValue: string) => void; + measureParentContainer: (callback: () => void) => void; +}; function ComposerWithSuggestionsEdit( { @@ -42,7 +43,7 @@ function ComposerWithSuggestionsEdit( suggestionsRef, updateDraft, measureParentContainer, - id = undefined + id = undefined, }: ComposerWithSuggestionsEditProps & ComposerProps, ref: ForwardedRef>>, ) { @@ -88,7 +89,7 @@ function ComposerWithSuggestionsEdit( resetKeyboardInput={resetKeyboardInput} /> - ) + ); } -export default React.forwardRef(ComposerWithSuggestionsEdit); \ No newline at end of file +export default React.forwardRef(ComposerWithSuggestionsEdit); diff --git a/src/pages/home/report/ReportActionCompose/types.ts b/src/pages/home/report/ReportActionCompose/types.ts deleted file mode 100644 index 8630189d45ec..000000000000 --- a/src/pages/home/report/ReportActionCompose/types.ts +++ /dev/null @@ -1,14 +0,0 @@ -import type { NativeSyntheticEvent, TextInputKeyPressEventData, TextInputSelectionChangeEventData } from "react-native"; - -type SuggestionsRef = { - getSuggestions: () => void; - resetSuggestions: () => void; - triggerHotkeyActions: (event: NativeSyntheticEvent | KeyboardEvent) => void; - onSelectionChange: (event: NativeSyntheticEvent) => void; - updateShouldShowSuggestionMenuToFalse: () => void; - setShouldBlockSuggestionCalc: () => void; -} - - -// eslint-disable-next-line import/prefer-default-export -export type {SuggestionsRef}; \ No newline at end of file diff --git a/src/pages/home/report/ReportActionItemMessageEdit.tsx b/src/pages/home/report/ReportActionItemMessageEdit.tsx index 7d13bd6335ca..a6535b83d0f7 100644 --- a/src/pages/home/report/ReportActionItemMessageEdit.tsx +++ b/src/pages/home/report/ReportActionItemMessageEdit.tsx @@ -3,7 +3,7 @@ import Str from 'expensify-common/lib/str'; import lodashDebounce from 'lodash/debounce'; import type {ForwardedRef} from 'react'; import React, {forwardRef, useCallback, useEffect, useMemo, useRef, useState} from 'react'; -import {InteractionManager, Keyboard, NativeModules, View, findNodeHandle} from 'react-native'; +import {findNodeHandle, InteractionManager, Keyboard, NativeModules, View} from 'react-native'; import type {NativeSyntheticEvent, TextInput, TextInputFocusEventData, TextInputKeyPressEventData} from 'react-native'; import type {Emoji} from '@assets/emojis/types'; import EmojiPickerButton from '@components/EmojiPicker/EmojiPickerButton'; @@ -20,6 +20,7 @@ import useStyleUtils from '@hooks/useStyleUtils'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import useWindowDimensions from '@hooks/useWindowDimensions'; +import * as SuggestionsAction from '@libs/actions/SuggestionsAction'; import * as Browser from '@libs/Browser'; import * as ComposerUtils from '@libs/ComposerUtils'; import * as EmojiUtils from '@libs/EmojiUtils'; @@ -38,11 +39,10 @@ import ONYXKEYS from '@src/ONYXKEYS'; import type * as OnyxTypes from '@src/types/onyx'; import * as ReportActionContextMenu from './ContextMenu/ReportActionContextMenu'; import ComposerWithSuggestionsEdit from './ReportActionCompose/ComposerWithSuggestionsEdit/ComposerWithSuggestionsEdit'; -import type { SuggestionsRef } from './ReportActionCompose/types'; +import { PortalHost } from '@gorhom/portal'; const {RNTextInputReset} = NativeModules; - type ReportActionItemMessageEditProps = { /** All the data of the action */ action: OnyxTypes.ReportAction; @@ -126,7 +126,6 @@ function ReportActionItemMessageEdit( const isFocusedRef = useRef(false); const insertedEmojis = useRef([]); const draftRef = useRef(draft); - const suggestionsRef = useRef(); const containerRef = useRef(null); useEffect(() => { @@ -182,7 +181,7 @@ function ReportActionItemMessageEdit( // and subsequent programmatic focus shifts (e.g., modal focus trap) to show the blue frame (:focus-visible style), // so we need to ensure that it is only updated after focus. if (isMobileSafari) { - setDraft((prevDraft) => { + setDraft((prevDraft: string) => { setSelection({ start: prevDraft.length, end: prevDraft.length, @@ -257,9 +256,7 @@ function ReportActionItemMessageEdit( if (emojis?.length > 0) { const newEmojis = EmojiUtils.getAddedEmojis(emojis, emojisPresentBefore.current); if (newEmojis?.length > 0) { - if (suggestionsRef.current) { - suggestionsRef.current.resetSuggestions(); - } + SuggestionsAction.resetSuggestions(); insertedEmojis.current = [...insertedEmojis.current, ...newEmojis]; debouncedUpdateFrequentlyUsedEmojis(); } @@ -361,8 +358,8 @@ function ReportActionItemMessageEdit( */ const triggerSaveOrCancel = useCallback( (e: NativeSyntheticEvent | KeyboardEvent) => { - if (suggestionsRef.current) { - suggestionsRef.current.triggerHotkeyActions(e); + if (SuggestionsAction.triggerHotkeyActions(e)) { + return; } if (!e || ComposerUtils.canSkipTriggerHotkeys(isSmallScreenWidth, isKeyboardShown)) { @@ -408,8 +405,6 @@ function ReportActionItemMessageEdit( validateCommentMaxLength(draft); }, [draft, validateCommentMaxLength]); - - return ( <> @@ -423,6 +418,7 @@ function ReportActionItemMessageEdit( hasExceededMaxCommentLength && styles.borderColorDanger, ]} > + { - if (suggestionsRef.current) { - suggestionsRef.current.onSelectionChange(e) - } - setSelection(e.nativeEvent.selection) + SuggestionsAction.onSelectionChange(e); + setSelection(e.nativeEvent.selection); }} setValue={setDraft} setSelection={setSelection} isComposerFocused={!!textInputRef.current && textInputRef.current.isFocused()} resetKeyboardInput={resetKeyboardInput} - suggestionsRef={suggestionsRef} + suggestionsRef={SuggestionsAction.suggestionsRef} updateDraft={updateDraft} measureParentContainer={measureContainer} /> diff --git a/src/pages/home/report/ReportActionsView.js b/src/pages/home/report/ReportActionsView.js index 4843a29dde60..1c0719645d2f 100755 --- a/src/pages/home/report/ReportActionsView.js +++ b/src/pages/home/report/ReportActionsView.js @@ -11,6 +11,7 @@ import withWindowDimensions, {windowDimensionsPropTypes} from '@components/withW import useCopySelectionHelper from '@hooks/useCopySelectionHelper'; import useInitialValue from '@hooks/useInitialValue'; import usePrevious from '@hooks/usePrevious'; +import * as SuggestionsAction from '@libs/actions/SuggestionsAction'; import compose from '@libs/compose'; import getIsReportFullyVisible from '@libs/getIsReportFullyVisible'; import Performance from '@libs/Performance'; @@ -255,6 +256,7 @@ function ReportActionsView(props) { Date: Tue, 6 Feb 2024 23:42:11 +0700 Subject: [PATCH 07/24] merge main --- .../ComposerWithSuggestionsEdit.tsx | 5 ++--- src/pages/home/report/ReportActionItemMessageEdit.tsx | 2 +- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/src/pages/home/report/ReportActionCompose/ComposerWithSuggestionsEdit/ComposerWithSuggestionsEdit.tsx b/src/pages/home/report/ReportActionCompose/ComposerWithSuggestionsEdit/ComposerWithSuggestionsEdit.tsx index 927d160a8b7f..02a629c954e2 100644 --- a/src/pages/home/report/ReportActionCompose/ComposerWithSuggestionsEdit/ComposerWithSuggestionsEdit.tsx +++ b/src/pages/home/report/ReportActionCompose/ComposerWithSuggestionsEdit/ComposerWithSuggestionsEdit.tsx @@ -1,7 +1,6 @@ import type {Dispatch, ForwardedRef, RefObject, SetStateAction} from 'react'; import React, {useState} from 'react'; -import type {TextInputProps} from 'react-native'; -import type {AnimatedProps} from 'react-native-reanimated'; +import type {TextInput} from 'react-native'; import Composer from '@components/Composer'; import type {ComposerProps} from '@components/Composer/types'; import type {SuggestionsRef} from '@libs/actions/SuggestionsAction'; @@ -45,7 +44,7 @@ function ComposerWithSuggestionsEdit( measureParentContainer, id = undefined, }: ComposerWithSuggestionsEditProps & ComposerProps, - ref: ForwardedRef>>, + ref: ForwardedRef, ) { const [composerHeight, setComposerHeight] = useState(0); diff --git a/src/pages/home/report/ReportActionItemMessageEdit.tsx b/src/pages/home/report/ReportActionItemMessageEdit.tsx index 8dad9fce6003..a89b509a5f9a 100644 --- a/src/pages/home/report/ReportActionItemMessageEdit.tsx +++ b/src/pages/home/report/ReportActionItemMessageEdit.tsx @@ -1,3 +1,4 @@ +import {PortalHost} from '@gorhom/portal'; import ExpensiMark from 'expensify-common/lib/ExpensiMark'; import Str from 'expensify-common/lib/str'; import lodashDebounce from 'lodash/debounce'; @@ -39,7 +40,6 @@ import ONYXKEYS from '@src/ONYXKEYS'; import type * as OnyxTypes from '@src/types/onyx'; import * as ReportActionContextMenu from './ContextMenu/ReportActionContextMenu'; import ComposerWithSuggestionsEdit from './ReportActionCompose/ComposerWithSuggestionsEdit/ComposerWithSuggestionsEdit'; -import { PortalHost } from '@gorhom/portal'; const {RNTextInputReset} = NativeModules; From f11b8ed46d3c87315354c73370f5123763d2fcd8 Mon Sep 17 00:00:00 2001 From: dukenv0307 Date: Mon, 19 Feb 2024 13:35:45 +0700 Subject: [PATCH 08/24] fix lint --- .../AutoCompleteSuggestions/BaseAutoCompleteSuggestions.tsx | 4 ++-- src/components/AutoCompleteSuggestions/index.tsx | 6 +++--- src/components/AutoCompleteSuggestions/types.ts | 2 +- src/libs/SuggestionUtils.ts | 4 ++-- src/pages/home/report/ReportActionItemMessageEdit.tsx | 3 +-- src/styles/utils/index.ts | 4 ++-- 6 files changed, 11 insertions(+), 12 deletions(-) diff --git a/src/components/AutoCompleteSuggestions/BaseAutoCompleteSuggestions.tsx b/src/components/AutoCompleteSuggestions/BaseAutoCompleteSuggestions.tsx index 2b7750f732f2..7b4d23d0f3e7 100644 --- a/src/components/AutoCompleteSuggestions/BaseAutoCompleteSuggestions.tsx +++ b/src/components/AutoCompleteSuggestions/BaseAutoCompleteSuggestions.tsx @@ -39,7 +39,7 @@ function BaseAutoCompleteSuggestions( suggestions, isSuggestionPickerLarge, keyExtractor, - shouldBelowParentContainer = false, + shouldBeDisplayedBelowParentContainer = false, }: AutoCompleteSuggestionsProps, ref: ForwardedRef, ) { @@ -68,7 +68,7 @@ function BaseAutoCompleteSuggestions( ); const innerHeight = CONST.AUTO_COMPLETE_SUGGESTER.SUGGESTION_ROW_HEIGHT * suggestions.length; - const animatedStyles = useAnimatedStyle(() => StyleUtils.getAutoCompleteSuggestionContainerStyle(rowHeight.value, shouldBelowParentContainer)); + const animatedStyles = useAnimatedStyle(() => StyleUtils.getAutoCompleteSuggestionContainerStyle(rowHeight.value, shouldBeDisplayedBelowParentContainer)); const estimatedListSize = useMemo( () => ({ height: CONST.AUTO_COMPLETE_SUGGESTER.SUGGESTION_ROW_HEIGHT * suggestions.length, diff --git a/src/components/AutoCompleteSuggestions/index.tsx b/src/components/AutoCompleteSuggestions/index.tsx index bda8e0aa7711..1f5d87b15962 100644 --- a/src/components/AutoCompleteSuggestions/index.tsx +++ b/src/components/AutoCompleteSuggestions/index.tsx @@ -4,7 +4,7 @@ import {View} from 'react-native'; import useStyleUtils from '@hooks/useStyleUtils'; import useWindowDimensions from '@hooks/useWindowDimensions'; import * as DeviceCapabilities from '@libs/DeviceCapabilities'; -import {measureHeightOfSuggestioContainer} from '@libs/SuggestionUtils'; +import {measureHeightOfSuggestionsContainer} from '@libs/SuggestionUtils'; import BaseAutoCompleteSuggestions from './BaseAutoCompleteSuggestions'; import type {AutoCompleteSuggestionsProps} from './types'; @@ -19,7 +19,7 @@ function AutoCompleteSuggestions({measureParentContainer = () => {} const StyleUtils = useStyleUtils(); const containerRef = React.useRef(null); const {windowHeight, windowWidth} = useWindowDimensions(); - const suggestionContainerHeight = measureHeightOfSuggestioContainer(props.suggestions.length, props.isSuggestionPickerLarge); + const suggestionContainerHeight = measureHeightOfSuggestionsContainer(props.suggestions.length, props.isSuggestionPickerLarge); const [{width, left, bottom}, setContainerState] = React.useState({ width: 0, left: 0, @@ -56,7 +56,7 @@ function AutoCompleteSuggestions({measureParentContainer = () => {} // eslint-disable-next-line react/jsx-props-no-spreading {...props} - shouldBelowParentContainer={shouldBelowContainer} + shouldBeDisplayedBelowParentContainer={shouldBelowContainer} ref={containerRef} /> ); diff --git a/src/components/AutoCompleteSuggestions/types.ts b/src/components/AutoCompleteSuggestions/types.ts index b837901026b2..d9824db1988d 100644 --- a/src/components/AutoCompleteSuggestions/types.ts +++ b/src/components/AutoCompleteSuggestions/types.ts @@ -35,7 +35,7 @@ type AutoCompleteSuggestionsProps = { measureParentContainer?: (callback: MeasureParentContainerCallback) => void; /** Whether suggestion should be displayed below the parent container or not */ - shouldBelowParentContainer?: boolean; + shouldBeDisplayedBelowParentContainer?: boolean; }; export type {AutoCompleteSuggestionsProps, RenderSuggestionMenuItemProps}; diff --git a/src/libs/SuggestionUtils.ts b/src/libs/SuggestionUtils.ts index 00039592ba77..f61989133409 100644 --- a/src/libs/SuggestionUtils.ts +++ b/src/libs/SuggestionUtils.ts @@ -20,7 +20,7 @@ function hasEnoughSpaceForLargeSuggestionMenu(listHeight: number, composerHeight return availableHeight > menuHeight; } -const measureHeightOfSuggestioContainer = (numRows: number, isSuggestionPickerLarge: boolean): number => { +const measureHeightOfSuggestionsContainer = (numRows: number, isSuggestionPickerLarge: boolean): number => { const borderAndPadding = CONST.AUTO_COMPLETE_SUGGESTER.SUGGESTER_PADDING + 2; let suggestionHeight = 0; @@ -40,4 +40,4 @@ const measureHeightOfSuggestioContainer = (numRows: number, isSuggestionPickerLa return suggestionHeight + borderAndPadding; }; -export {trimLeadingSpace, hasEnoughSpaceForLargeSuggestionMenu, measureHeightOfSuggestioContainer}; +export {trimLeadingSpace, hasEnoughSpaceForLargeSuggestionMenu, measureHeightOfSuggestionsContainer}; diff --git a/src/pages/home/report/ReportActionItemMessageEdit.tsx b/src/pages/home/report/ReportActionItemMessageEdit.tsx index 1ef9406c6327..bdd41a27baa6 100644 --- a/src/pages/home/report/ReportActionItemMessageEdit.tsx +++ b/src/pages/home/report/ReportActionItemMessageEdit.tsx @@ -4,8 +4,7 @@ import Str from 'expensify-common/lib/str'; import lodashDebounce from 'lodash/debounce'; import type {ForwardedRef} from 'react'; import React, {forwardRef, useCallback, useEffect, useMemo, useRef, useState} from 'react'; -import {findNodeHandle, InteractionManager, Keyboard, NativeModules, View} from 'react-native'; -import {Keyboard, View} from 'react-native'; +import {findNodeHandle, Keyboard, NativeModules, View} from 'react-native'; import type {NativeSyntheticEvent, TextInput, TextInputFocusEventData, TextInputKeyPressEventData} from 'react-native'; import type {Emoji} from '@assets/emojis/types'; import EmojiPickerButton from '@components/EmojiPicker/EmojiPickerButton'; diff --git a/src/styles/utils/index.ts b/src/styles/utils/index.ts index ec3f088da537..8ca45907449d 100644 --- a/src/styles/utils/index.ts +++ b/src/styles/utils/index.ts @@ -793,7 +793,7 @@ function getBaseAutoCompleteSuggestionContainerStyle({left, bottom, width}: GetB /** * Gets the correct position for auto complete suggestion container */ -function getAutoCompleteSuggestionContainerStyle(itemsHeight: number, shouldBelowParentContainer: boolean): ViewStyle { +function getAutoCompleteSuggestionContainerStyle(itemsHeight: number, shouldBeDisplayedBelowParentContainer: boolean): ViewStyle { 'worklet'; const borderWidth = 2; @@ -803,7 +803,7 @@ function getAutoCompleteSuggestionContainerStyle(itemsHeight: number, shouldBelo // we need to shift it by the suggester's height plus its padding and, if applicable, the height of the RecipientLocalTime view. return { overflow: 'hidden', - top: -(height + (shouldBelowParentContainer ? -2 : 1) * (CONST.AUTO_COMPLETE_SUGGESTER.SUGGESTER_PADDING + borderWidth)), + top: -(height + (shouldBeDisplayedBelowParentContainer ? -2 : 1) * (CONST.AUTO_COMPLETE_SUGGESTER.SUGGESTER_PADDING + borderWidth)), height, minHeight: CONST.AUTO_COMPLETE_SUGGESTER.SUGGESTION_ROW_HEIGHT, }; From a7bcde62060bef47d9675cea3d300fb156fa170f Mon Sep 17 00:00:00 2001 From: dukenv0307 Date: Mon, 19 Feb 2024 13:37:25 +0700 Subject: [PATCH 09/24] update suggestion --- .../actions/{SuggestionsAction.ts => SuggestionsActions.ts} | 0 .../ComposerWithSuggestionsEdit/ComposerWithSuggestionsEdit.tsx | 2 +- src/pages/home/report/ReportActionItemMessageEdit.tsx | 2 +- src/pages/home/report/ReportActionsView.js | 2 +- src/styles/utils/index.ts | 2 +- 5 files changed, 4 insertions(+), 4 deletions(-) rename src/libs/actions/{SuggestionsAction.ts => SuggestionsActions.ts} (100%) diff --git a/src/libs/actions/SuggestionsAction.ts b/src/libs/actions/SuggestionsActions.ts similarity index 100% rename from src/libs/actions/SuggestionsAction.ts rename to src/libs/actions/SuggestionsActions.ts diff --git a/src/pages/home/report/ReportActionCompose/ComposerWithSuggestionsEdit/ComposerWithSuggestionsEdit.tsx b/src/pages/home/report/ReportActionCompose/ComposerWithSuggestionsEdit/ComposerWithSuggestionsEdit.tsx index 02a629c954e2..379928e8f28f 100644 --- a/src/pages/home/report/ReportActionCompose/ComposerWithSuggestionsEdit/ComposerWithSuggestionsEdit.tsx +++ b/src/pages/home/report/ReportActionCompose/ComposerWithSuggestionsEdit/ComposerWithSuggestionsEdit.tsx @@ -3,7 +3,7 @@ import React, {useState} from 'react'; import type {TextInput} from 'react-native'; import Composer from '@components/Composer'; import type {ComposerProps} from '@components/Composer/types'; -import type {SuggestionsRef} from '@libs/actions/SuggestionsAction'; +import type {SuggestionsRef} from '@libs/actions/SuggestionsActions'; import Suggestions from '@pages/home/report/ReportActionCompose/Suggestions'; type ComposerWithSuggestionsEditProps = { diff --git a/src/pages/home/report/ReportActionItemMessageEdit.tsx b/src/pages/home/report/ReportActionItemMessageEdit.tsx index bdd41a27baa6..226576e8b916 100644 --- a/src/pages/home/report/ReportActionItemMessageEdit.tsx +++ b/src/pages/home/report/ReportActionItemMessageEdit.tsx @@ -21,7 +21,7 @@ import useStyleUtils from '@hooks/useStyleUtils'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import useWindowDimensions from '@hooks/useWindowDimensions'; -import * as SuggestionsAction from '@libs/actions/SuggestionsAction'; +import * as SuggestionsAction from '@libs/actions/SuggestionsActions'; import * as Browser from '@libs/Browser'; import * as ComposerUtils from '@libs/ComposerUtils'; import * as EmojiUtils from '@libs/EmojiUtils'; diff --git a/src/pages/home/report/ReportActionsView.js b/src/pages/home/report/ReportActionsView.js index a76814caf147..cf23fb76d052 100755 --- a/src/pages/home/report/ReportActionsView.js +++ b/src/pages/home/report/ReportActionsView.js @@ -11,7 +11,7 @@ import withWindowDimensions, {windowDimensionsPropTypes} from '@components/withW import useCopySelectionHelper from '@hooks/useCopySelectionHelper'; import useInitialValue from '@hooks/useInitialValue'; import usePrevious from '@hooks/usePrevious'; -import * as SuggestionsAction from '@libs/actions/SuggestionsAction'; +import * as SuggestionsAction from '@libs/actions/SuggestionsActions'; import compose from '@libs/compose'; import getIsReportFullyVisible from '@libs/getIsReportFullyVisible'; import Performance from '@libs/Performance'; diff --git a/src/styles/utils/index.ts b/src/styles/utils/index.ts index 8ca45907449d..61ef018350df 100644 --- a/src/styles/utils/index.ts +++ b/src/styles/utils/index.ts @@ -783,7 +783,7 @@ type GetBaseAutoCompleteSuggestionContainerStyleParams = { */ function getBaseAutoCompleteSuggestionContainerStyle({left, bottom, width}: GetBaseAutoCompleteSuggestionContainerStyleParams): ViewStyle { return { - // ...positioning.pFixed, + ...positioning.pFixed, bottom, left, width, From be5faa2661dced9170ec02ff85c302d1f23eb726 Mon Sep 17 00:00:00 2001 From: dukenv0307 Date: Mon, 19 Feb 2024 13:39:21 +0700 Subject: [PATCH 10/24] rename variable --- src/libs/SuggestionUtils.ts | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/src/libs/SuggestionUtils.ts b/src/libs/SuggestionUtils.ts index f61989133409..414244d77e13 100644 --- a/src/libs/SuggestionUtils.ts +++ b/src/libs/SuggestionUtils.ts @@ -20,24 +20,24 @@ function hasEnoughSpaceForLargeSuggestionMenu(listHeight: number, composerHeight return availableHeight > menuHeight; } -const measureHeightOfSuggestionsContainer = (numRows: number, isSuggestionPickerLarge: boolean): number => { +const measureHeightOfSuggestionsContainer = (numRows: number, isSuggestionsPickerLarge: boolean): number => { const borderAndPadding = CONST.AUTO_COMPLETE_SUGGESTER.SUGGESTER_PADDING + 2; - let suggestionHeight = 0; + let suggestionsHeight = 0; - if (isSuggestionPickerLarge) { + if (isSuggestionsPickerLarge) { if (numRows > CONST.AUTO_COMPLETE_SUGGESTER.MAX_AMOUNT_OF_VISIBLE_SUGGESTIONS_IN_CONTAINER) { // On large screens, if there are more than 5 suggestions, we display a scrollable window with a height of 5 items, indicating that there are more items available - suggestionHeight = CONST.AUTO_COMPLETE_SUGGESTER.MAX_AMOUNT_OF_VISIBLE_SUGGESTIONS_IN_CONTAINER * CONST.AUTO_COMPLETE_SUGGESTER.SUGGESTION_ROW_HEIGHT; + suggestionsHeight = CONST.AUTO_COMPLETE_SUGGESTER.MAX_AMOUNT_OF_VISIBLE_SUGGESTIONS_IN_CONTAINER * CONST.AUTO_COMPLETE_SUGGESTER.SUGGESTION_ROW_HEIGHT; } else { - suggestionHeight = numRows * CONST.AUTO_COMPLETE_SUGGESTER.SUGGESTION_ROW_HEIGHT; + suggestionsHeight = numRows * CONST.AUTO_COMPLETE_SUGGESTER.SUGGESTION_ROW_HEIGHT; } } else if (numRows > 2) { // On small screens, we display a scrollable window with a height of 2.5 items, indicating that there are more items available beyond what is currently visible - suggestionHeight = CONST.AUTO_COMPLETE_SUGGESTER.SMALL_CONTAINER_HEIGHT_FACTOR * CONST.AUTO_COMPLETE_SUGGESTER.SUGGESTION_ROW_HEIGHT; + suggestionsHeight = CONST.AUTO_COMPLETE_SUGGESTER.SMALL_CONTAINER_HEIGHT_FACTOR * CONST.AUTO_COMPLETE_SUGGESTER.SUGGESTION_ROW_HEIGHT; } else { - suggestionHeight = numRows * CONST.AUTO_COMPLETE_SUGGESTER.SUGGESTION_ROW_HEIGHT; + suggestionsHeight = numRows * CONST.AUTO_COMPLETE_SUGGESTER.SUGGESTION_ROW_HEIGHT; } - return suggestionHeight + borderAndPadding; + return suggestionsHeight + borderAndPadding; }; export {trimLeadingSpace, hasEnoughSpaceForLargeSuggestionMenu, measureHeightOfSuggestionsContainer}; From eb09849b6f8d18f52ab18e4942e5ed20b19118ae Mon Sep 17 00:00:00 2001 From: dukenv0307 Date: Mon, 26 Feb 2024 11:53:05 +0700 Subject: [PATCH 11/24] move portal of suggestion to the correct place --- src/pages/home/report/ReportActionItemMessageEdit.tsx | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/pages/home/report/ReportActionItemMessageEdit.tsx b/src/pages/home/report/ReportActionItemMessageEdit.tsx index 2c9190926d0d..6a182a6016c3 100644 --- a/src/pages/home/report/ReportActionItemMessageEdit.tsx +++ b/src/pages/home/report/ReportActionItemMessageEdit.tsx @@ -401,9 +401,12 @@ function ReportActionItemMessageEdit( return ( <> - + + - Date: Mon, 4 Mar 2024 17:18:45 +0700 Subject: [PATCH 12/24] remove global ref and create suggestion context --- src/App.tsx | 2 + src/libs/actions/SuggestionsActions.ts | 50 ------------------- .../ComposerWithSuggestionsEdit.tsx | 25 +++++----- .../SuggestionsContext.tsx | 50 +++++++++++++++++++ .../report/ReportActionItemMessageEdit.tsx | 21 +++++--- src/pages/home/report/ReportActionsView.js | 10 +++- 6 files changed, 87 insertions(+), 71 deletions(-) delete mode 100644 src/libs/actions/SuggestionsActions.ts create mode 100644 src/pages/home/report/ReportActionCompose/ComposerWithSuggestionsEdit/SuggestionsContext.tsx diff --git a/src/App.tsx b/src/App.tsx index cbe5948f8d4e..03dc3c328488 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -31,6 +31,7 @@ import Expensify from './Expensify'; import useDefaultDragAndDrop from './hooks/useDefaultDragAndDrop'; import OnyxUpdateManager from './libs/actions/OnyxUpdateManager'; import InitialUrlContext from './libs/InitialUrlContext'; +import {SuggestionsContextProvider} from './pages/home/report/ReportActionCompose/ComposerWithSuggestionsEdit/SuggestionsContext'; import {ReportAttachmentsProvider} from './pages/home/report/ReportAttachmentsContext'; import type {Route} from './ROUTES'; @@ -76,6 +77,7 @@ function App({url}: AppProps) { ActiveElementRoleProvider, ActiveWorkspaceContextProvider, PlaybackContextProvider, + SuggestionsContextProvider, VolumeContextProvider, VideoPopoverMenuContextProvider, ]} diff --git a/src/libs/actions/SuggestionsActions.ts b/src/libs/actions/SuggestionsActions.ts deleted file mode 100644 index 9fecd18f7d10..000000000000 --- a/src/libs/actions/SuggestionsActions.ts +++ /dev/null @@ -1,50 +0,0 @@ -import React from 'react'; -import type {NativeSyntheticEvent, TextInputKeyPressEventData, TextInputSelectionChangeEventData} from 'react-native'; - -type SuggestionsRef = { - getSuggestions: () => void; - resetSuggestions: () => void; - triggerHotkeyActions: (event: NativeSyntheticEvent | KeyboardEvent) => boolean; - onSelectionChange: (event: NativeSyntheticEvent) => void; - updateShouldShowSuggestionMenuToFalse: () => void; - setShouldBlockSuggestionCalc: () => void; -}; - -const suggestionsRef = React.createRef(); - -function resetSuggestions() { - if (!suggestionsRef.current) { - return; - } - - suggestionsRef.current.resetSuggestions(); -} - -function triggerHotkeyActions(event: NativeSyntheticEvent | KeyboardEvent): boolean { - if (!suggestionsRef.current) { - return false; - } - - return suggestionsRef.current.triggerHotkeyActions(event); -} - -function updateShouldShowSuggestionMenuToFalse() { - if (!suggestionsRef.current) { - return; - } - - suggestionsRef.current.updateShouldShowSuggestionMenuToFalse(); -} - -function onSelectionChange(event: NativeSyntheticEvent) { - if (!suggestionsRef.current) { - return; - } - - suggestionsRef.current.onSelectionChange(event); -} - -export {suggestionsRef, resetSuggestions, triggerHotkeyActions, onSelectionChange, updateShouldShowSuggestionMenuToFalse}; - -// eslint-disable-next-line import/prefer-default-export -export type {SuggestionsRef}; diff --git a/src/pages/home/report/ReportActionCompose/ComposerWithSuggestionsEdit/ComposerWithSuggestionsEdit.tsx b/src/pages/home/report/ReportActionCompose/ComposerWithSuggestionsEdit/ComposerWithSuggestionsEdit.tsx index 379928e8f28f..ce8cfa1dc356 100644 --- a/src/pages/home/report/ReportActionCompose/ComposerWithSuggestionsEdit/ComposerWithSuggestionsEdit.tsx +++ b/src/pages/home/report/ReportActionCompose/ComposerWithSuggestionsEdit/ComposerWithSuggestionsEdit.tsx @@ -1,24 +1,26 @@ import type {Dispatch, ForwardedRef, RefObject, SetStateAction} from 'react'; import React, {useState} from 'react'; -import type {TextInput} from 'react-native'; +import type {MeasureInWindowOnSuccessCallback, TextInput} from 'react-native'; import Composer from '@components/Composer'; import type {ComposerProps} from '@components/Composer/types'; -import type {SuggestionsRef} from '@libs/actions/SuggestionsActions'; +import type {SuggestionsRef} from '@pages/home/report/ReportActionCompose/ReportActionCompose'; import Suggestions from '@pages/home/report/ReportActionCompose/Suggestions'; -type ComposerWithSuggestionsEditProps = { +type Selection = { + start: number; + end: number; +}; + +type ComposerWithSuggestionsEditProps = ComposerProps & { setValue: Dispatch>; - setSelection: Dispatch< - SetStateAction<{ - start: number; - end: number; - }> - >; + setSelection: Dispatch>; resetKeyboardInput: () => void; isComposerFocused: boolean; suggestionsRef: RefObject; updateDraft: (newValue: string) => void; - measureParentContainer: (callback: () => void) => void; + measureParentContainer: (callback: MeasureInWindowOnSuccessCallback) => void; + value: string; + selection: Selection; }; function ComposerWithSuggestionsEdit( @@ -43,7 +45,7 @@ function ComposerWithSuggestionsEdit( updateDraft, measureParentContainer, id = undefined, - }: ComposerWithSuggestionsEditProps & ComposerProps, + }: ComposerWithSuggestionsEditProps, ref: ForwardedRef, ) { const [composerHeight, setComposerHeight] = useState(0); @@ -74,7 +76,6 @@ function ComposerWithSuggestionsEdit( ; + updateCurrentActiveSuggestionsRef: (ref: SuggestionsRef | null, id: string) => void; + clearActiveSuggestionsRef: () => void; +}; + +const SuggestionsContext = createContext({ + currentActiveSuggestionsRef: {current: null}, + updateCurrentActiveSuggestionsRef: () => {}, + clearActiveSuggestionsRef: () => {}, +}); + +function SuggestionsContextProvider({children}: SuggestionsContextProviderProps) { + const currentActiveSuggestionsRef = useRef(null); + const [activeID, setActiveID] = useState(null); + + const updateCurrentActiveSuggestionsRef = useCallback((ref: SuggestionsRef | null, id: string) => { + currentActiveSuggestionsRef.current = ref; + setActiveID(id); + }, []); + + const clearActiveSuggestionsRef = useCallback(() => { + currentActiveSuggestionsRef.current = null; + setActiveID(null); + }, []); + + const contextValue = useMemo( + () => ({activeID, currentActiveSuggestionsRef, updateCurrentActiveSuggestionsRef, clearActiveSuggestionsRef}), + [activeID, currentActiveSuggestionsRef, updateCurrentActiveSuggestionsRef, clearActiveSuggestionsRef], + ); + + return {children}; +} + +function useSuggestionsContext() { + const context = useContext(SuggestionsContext); + return context; +} + +SuggestionsContextProvider.displayName = 'PlaybackContextProvider'; + +export {SuggestionsContextProvider, useSuggestionsContext}; diff --git a/src/pages/home/report/ReportActionItemMessageEdit.tsx b/src/pages/home/report/ReportActionItemMessageEdit.tsx index 6a182a6016c3..ad516946fecf 100644 --- a/src/pages/home/report/ReportActionItemMessageEdit.tsx +++ b/src/pages/home/report/ReportActionItemMessageEdit.tsx @@ -5,7 +5,7 @@ import lodashDebounce from 'lodash/debounce'; import type {ForwardedRef} from 'react'; import React, {forwardRef, useCallback, useEffect, useMemo, useRef, useState} from 'react'; import {findNodeHandle, Keyboard, NativeModules, View} from 'react-native'; -import type {NativeSyntheticEvent, TextInput, TextInputFocusEventData, TextInputKeyPressEventData} from 'react-native'; +import type {MeasureInWindowOnSuccessCallback, NativeSyntheticEvent, TextInput, TextInputFocusEventData, TextInputKeyPressEventData} from 'react-native'; import type {Emoji} from '@assets/emojis/types'; import EmojiPickerButton from '@components/EmojiPicker/EmojiPickerButton'; import ExceededCommentLength from '@components/ExceededCommentLength'; @@ -21,7 +21,6 @@ import useStyleUtils from '@hooks/useStyleUtils'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import useWindowDimensions from '@hooks/useWindowDimensions'; -import * as SuggestionsAction from '@libs/actions/SuggestionsActions'; import * as Browser from '@libs/Browser'; import * as ComposerUtils from '@libs/ComposerUtils'; import * as EmojiUtils from '@libs/EmojiUtils'; @@ -42,6 +41,8 @@ import ONYXKEYS from '@src/ONYXKEYS'; import type * as OnyxTypes from '@src/types/onyx'; import * as ReportActionContextMenu from './ContextMenu/ReportActionContextMenu'; import ComposerWithSuggestionsEdit from './ReportActionCompose/ComposerWithSuggestionsEdit/ComposerWithSuggestionsEdit'; +import {useSuggestionsContext} from './ReportActionCompose/ComposerWithSuggestionsEdit/SuggestionsContext'; +import type {SuggestionsRef} from './ReportActionCompose/ReportActionCompose'; const {RNTextInputReset} = NativeModules; @@ -81,6 +82,7 @@ function ReportActionItemMessageEdit( const {translate, preferredLocale} = useLocalize(); const {isKeyboardShown} = useKeyboardState(); const {isSmallScreenWidth} = useWindowDimensions(); + const {updateCurrentActiveSuggestionsRef, clearActiveSuggestionsRef} = useSuggestionsContext(); const getInitialDraft = () => { if (draftMessage === action?.message?.[0].html) { @@ -125,6 +127,7 @@ function ReportActionItemMessageEdit( const insertedEmojis = useRef([]); const draftRef = useRef(draft); const containerRef = useRef(null); + const suggestionsRef = useRef(null); useEffect(() => { if (ReportActionsUtils.isDeletedAction(action) || (action.message && draftMessage === action.message[0].html)) { @@ -257,7 +260,7 @@ function ReportActionItemMessageEdit( if (emojis?.length > 0) { const newEmojis = EmojiUtils.getAddedEmojis(emojis, emojisPresentBefore.current); if (newEmojis?.length > 0) { - SuggestionsAction.resetSuggestions(); + suggestionsRef.current?.resetSuggestions(); insertedEmojis.current = [...insertedEmojis.current, ...newEmojis]; debouncedUpdateFrequentlyUsedEmojis(); } @@ -352,7 +355,7 @@ function ReportActionItemMessageEdit( */ const triggerSaveOrCancel = useCallback( (e: NativeSyntheticEvent | KeyboardEvent) => { - if (SuggestionsAction.triggerHotkeyActions(e)) { + if (suggestionsRef.current?.triggerHotkeyActions(e as KeyboardEvent)) { return; } @@ -379,7 +382,7 @@ function ReportActionItemMessageEdit( }, [textInputRef]); const measureContainer = useCallback( - (callback: () => void) => { + (callback: MeasureInWindowOnSuccessCallback) => { if (!containerRef.current) { return; } @@ -465,11 +468,15 @@ function ReportActionItemMessageEdit( if (!ReportActionContextMenu.isActiveReportAction(action.reportActionID)) { ReportActionContextMenu.clearActiveReportAction(); } + + updateCurrentActiveSuggestionsRef(suggestionsRef.current, action.reportActionID); }} onBlur={(event: NativeSyntheticEvent) => { setIsFocused(false); // @ts-expect-error TODO: TextInputFocusEventData doesn't contain relatedTarget. const relatedTargetId = event.nativeEvent?.relatedTarget?.id; + suggestionsRef.current?.resetSuggestions(); + clearActiveSuggestionsRef(); if (relatedTargetId && [messageEditInput, emojiButtonID].includes(relatedTargetId)) { return; } @@ -477,14 +484,14 @@ function ReportActionItemMessageEdit( }} selection={selection} onSelectionChange={(e) => { - SuggestionsAction.onSelectionChange(e); + suggestionsRef.current?.onSelectionChange?.(e); setSelection(e.nativeEvent.selection); }} setValue={setDraft} setSelection={setSelection} isComposerFocused={!!textInputRef.current && textInputRef.current.isFocused()} resetKeyboardInput={resetKeyboardInput} - suggestionsRef={SuggestionsAction.suggestionsRef} + suggestionsRef={suggestionsRef} updateDraft={updateDraft} measureParentContainer={measureContainer} /> diff --git a/src/pages/home/report/ReportActionsView.js b/src/pages/home/report/ReportActionsView.js index 5a167dbb376f..a7584a3570dd 100755 --- a/src/pages/home/report/ReportActionsView.js +++ b/src/pages/home/report/ReportActionsView.js @@ -11,7 +11,6 @@ import withWindowDimensions, {windowDimensionsPropTypes} from '@components/withW import useCopySelectionHelper from '@hooks/useCopySelectionHelper'; import useInitialValue from '@hooks/useInitialValue'; import usePrevious from '@hooks/usePrevious'; -import * as SuggestionsAction from '@libs/actions/SuggestionsActions'; import compose from '@libs/compose'; import getIsReportFullyVisible from '@libs/getIsReportFullyVisible'; import Performance from '@libs/Performance'; @@ -25,6 +24,7 @@ import Timing from '@userActions/Timing'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import PopoverReactionList from './ReactionList/PopoverReactionList'; +import {useSuggestionsContext} from './ReportActionCompose/ComposerWithSuggestionsEdit/SuggestionsContext'; import reportActionPropTypes from './reportActionPropTypes'; import ReportActionsList from './ReportActionsList'; @@ -88,6 +88,7 @@ const defaultProps = { function ReportActionsView(props) { useCopySelectionHelper(); const reactionListRef = useContext(ReactionListContext); + const {currentActiveSuggestionsRef} = useSuggestionsContext(); const didLayout = useRef(false); const didSubscribeToReportTypingEvents = useRef(false); const isFirstRender = useRef(true); @@ -261,7 +262,12 @@ function ReportActionsView(props) { report={props.report} parentReportAction={props.parentReportAction} onLayout={recordTimeToMeasureItemLayout} - onScroll={SuggestionsAction.updateShouldShowSuggestionMenuToFalse} + onScroll={() => { + if (!currentActiveSuggestionsRef.current) { + return; + } + currentActiveSuggestionsRef.current.updateShouldShowSuggestionMenuToFalse(); + }} sortedReportActions={props.reportActions} mostRecentIOUReportActionID={mostRecentIOUReportActionID} loadOlderChats={loadOlderChats} From 8dcd362fdcb118b8e6a7108bbf26d722d55ca100 Mon Sep 17 00:00:00 2001 From: dukenv0307 Date: Fri, 15 Mar 2024 16:39:49 +0700 Subject: [PATCH 13/24] fix the suggestion padding for edit composer --- src/CONST.ts | 1 + .../BaseAutoCompleteSuggestions.tsx | 4 +++- .../ComposerWithSuggestionsEdit/SuggestionsContext.tsx | 10 ++++++++-- src/pages/home/report/ReportActionItemMessageEdit.tsx | 5 ++++- src/styles/utils/index.ts | 5 +++-- 5 files changed, 19 insertions(+), 6 deletions(-) diff --git a/src/CONST.ts b/src/CONST.ts index fa44cda20720..68fb72541845 100755 --- a/src/CONST.ts +++ b/src/CONST.ts @@ -1065,6 +1065,7 @@ const CONST = { EMOJI_PICKER_HEADER_HEIGHT: 32, RECIPIENT_LOCAL_TIME_HEIGHT: 25, AUTO_COMPLETE_SUGGESTER: { + EDIT_SUGGESTER_PADDING: 8, SUGGESTER_PADDING: 6, SUGGESTER_INNER_PADDING: 8, SUGGESTION_ROW_HEIGHT: 40, diff --git a/src/components/AutoCompleteSuggestions/BaseAutoCompleteSuggestions.tsx b/src/components/AutoCompleteSuggestions/BaseAutoCompleteSuggestions.tsx index 6c65afa9c2a8..a11fb2a01cc8 100644 --- a/src/components/AutoCompleteSuggestions/BaseAutoCompleteSuggestions.tsx +++ b/src/components/AutoCompleteSuggestions/BaseAutoCompleteSuggestions.tsx @@ -10,6 +10,7 @@ import PressableWithFeedback from '@components/Pressable/PressableWithFeedback'; import useStyleUtils from '@hooks/useStyleUtils'; import useThemeStyles from '@hooks/useThemeStyles'; import useWindowDimensions from '@hooks/useWindowDimensions'; +import {useSuggestionsContext} from '@pages/home/report/ReportActionCompose/ComposerWithSuggestionsEdit/SuggestionsContext'; import variables from '@styles/variables'; import CONST from '@src/CONST'; import viewForwardedRef from '@src/types/utils/viewForwardedRef'; @@ -48,6 +49,7 @@ function BaseAutoCompleteSuggestions( const StyleUtils = useStyleUtils(); const rowHeight = useSharedValue(0); const scrollRef = useRef>(null); + const {activeID} = useSuggestionsContext(); /** * Render a suggestion menu item component. */ @@ -69,7 +71,7 @@ function BaseAutoCompleteSuggestions( ); const innerHeight = CONST.AUTO_COMPLETE_SUGGESTER.SUGGESTION_ROW_HEIGHT * suggestions.length; - const animatedStyles = useAnimatedStyle(() => StyleUtils.getAutoCompleteSuggestionContainerStyle(rowHeight.value, shouldBeDisplayedBelowParentContainer)); + const animatedStyles = useAnimatedStyle(() => StyleUtils.getAutoCompleteSuggestionContainerStyle(rowHeight.value, shouldBeDisplayedBelowParentContainer, Boolean(activeID))); const estimatedListSize = useMemo( () => ({ height: CONST.AUTO_COMPLETE_SUGGESTER.SUGGESTION_ROW_HEIGHT * suggestions.length, diff --git a/src/pages/home/report/ReportActionCompose/ComposerWithSuggestionsEdit/SuggestionsContext.tsx b/src/pages/home/report/ReportActionCompose/ComposerWithSuggestionsEdit/SuggestionsContext.tsx index 9118e80ef230..36ed962010ab 100644 --- a/src/pages/home/report/ReportActionCompose/ComposerWithSuggestionsEdit/SuggestionsContext.tsx +++ b/src/pages/home/report/ReportActionCompose/ComposerWithSuggestionsEdit/SuggestionsContext.tsx @@ -7,15 +7,19 @@ type SuggestionsContextProviderProps = { }; type SuggestionsContextProps = { + activeID: string | null; currentActiveSuggestionsRef: MutableRefObject; updateCurrentActiveSuggestionsRef: (ref: SuggestionsRef | null, id: string) => void; clearActiveSuggestionsRef: () => void; + isActiveSuggestions: (id: string) => boolean; }; const SuggestionsContext = createContext({ + activeID: null, currentActiveSuggestionsRef: {current: null}, updateCurrentActiveSuggestionsRef: () => {}, clearActiveSuggestionsRef: () => {}, + isActiveSuggestions: () => false, }); function SuggestionsContextProvider({children}: SuggestionsContextProviderProps) { @@ -32,9 +36,11 @@ function SuggestionsContextProvider({children}: SuggestionsContextProviderProps) setActiveID(null); }, []); + const isActiveSuggestions = useCallback((id: string) => id === activeID, [activeID]); + const contextValue = useMemo( - () => ({activeID, currentActiveSuggestionsRef, updateCurrentActiveSuggestionsRef, clearActiveSuggestionsRef}), - [activeID, currentActiveSuggestionsRef, updateCurrentActiveSuggestionsRef, clearActiveSuggestionsRef], + () => ({activeID, currentActiveSuggestionsRef, updateCurrentActiveSuggestionsRef, clearActiveSuggestionsRef, isActiveSuggestions}), + [activeID, currentActiveSuggestionsRef, updateCurrentActiveSuggestionsRef, clearActiveSuggestionsRef, isActiveSuggestions], ); return {children}; diff --git a/src/pages/home/report/ReportActionItemMessageEdit.tsx b/src/pages/home/report/ReportActionItemMessageEdit.tsx index 858f33a5a8dc..294f7e83d66e 100644 --- a/src/pages/home/report/ReportActionItemMessageEdit.tsx +++ b/src/pages/home/report/ReportActionItemMessageEdit.tsx @@ -83,7 +83,7 @@ function ReportActionItemMessageEdit( const {translate, preferredLocale} = useLocalize(); const {isKeyboardShown} = useKeyboardState(); const {isSmallScreenWidth} = useWindowDimensions(); - const {updateCurrentActiveSuggestionsRef, clearActiveSuggestionsRef} = useSuggestionsContext(); + const {updateCurrentActiveSuggestionsRef, clearActiveSuggestionsRef, isActiveSuggestions} = useSuggestionsContext(); const getInitialDraft = () => { if (draftMessage === action?.message?.[0].html) { @@ -212,6 +212,9 @@ function ReportActionItemMessageEdit( if (ReportActionContextMenu.isActiveReportAction(action.reportActionID)) { ReportActionContextMenu.clearActiveReportAction(); } + if (isActiveSuggestions(action.reportActionID)) { + clearActiveSuggestionsRef(); + } // Show the main composer when the focused message is deleted from another client // to prevent the main composer stays hidden until we swtich to another chat. diff --git a/src/styles/utils/index.ts b/src/styles/utils/index.ts index 2a05278dec6c..1fde098c4aeb 100644 --- a/src/styles/utils/index.ts +++ b/src/styles/utils/index.ts @@ -844,17 +844,18 @@ const shouldPreventScroll = shouldPreventScrollOnAutoCompleteSuggestion(); /** * Gets the correct position for auto complete suggestion container */ -function getAutoCompleteSuggestionContainerStyle(itemsHeight: number, shouldBeDisplayedBelowParentContainer: boolean): ViewStyle { +function getAutoCompleteSuggestionContainerStyle(itemsHeight: number, shouldBeDisplayedBelowParentContainer: boolean, isEditComposer: boolean): ViewStyle { 'worklet'; const borderWidth = 2; const height = itemsHeight + 2 * CONST.AUTO_COMPLETE_SUGGESTER.SUGGESTER_INNER_PADDING + (shouldPreventScroll ? borderWidth : 0); + const suggestionsPadding = isEditComposer ? CONST.AUTO_COMPLETE_SUGGESTER.EDIT_SUGGESTER_PADDING : CONST.AUTO_COMPLETE_SUGGESTER.SUGGESTER_PADDING; // The suggester is positioned absolutely within the component that includes the input and RecipientLocalTime view (for non-expanded mode only). To position it correctly, // we need to shift it by the suggester's height plus its padding and, if applicable, the height of the RecipientLocalTime view. return { overflow: 'hidden', - top: -(height + (shouldBeDisplayedBelowParentContainer ? -2 : 1) * (CONST.AUTO_COMPLETE_SUGGESTER.SUGGESTER_PADDING + (shouldPreventScroll ? 0 : borderWidth))), + top: -(height + (shouldBeDisplayedBelowParentContainer ? -2 : 1) * (suggestionsPadding + (shouldPreventScroll ? 0 : borderWidth))), height, minHeight: CONST.AUTO_COMPLETE_SUGGESTER.SUGGESTION_ROW_HEIGHT, }; From 07db2abc4a3a1d5e6d64ce12cc0ec21274946d96 Mon Sep 17 00:00:00 2001 From: dukenv0307 Date: Tue, 19 Mar 2024 17:48:30 +0700 Subject: [PATCH 14/24] update variable name --- src/components/AutoCompleteSuggestions/index.tsx | 14 +++++++------- .../home/report/ReportActionItemMessageEdit.tsx | 1 - 2 files changed, 7 insertions(+), 8 deletions(-) diff --git a/src/components/AutoCompleteSuggestions/index.tsx b/src/components/AutoCompleteSuggestions/index.tsx index 1f5d87b15962..66ea0de6f9f3 100644 --- a/src/components/AutoCompleteSuggestions/index.tsx +++ b/src/components/AutoCompleteSuggestions/index.tsx @@ -19,13 +19,13 @@ function AutoCompleteSuggestions({measureParentContainer = () => {} const StyleUtils = useStyleUtils(); const containerRef = React.useRef(null); const {windowHeight, windowWidth} = useWindowDimensions(); - const suggestionContainerHeight = measureHeightOfSuggestionsContainer(props.suggestions.length, props.isSuggestionPickerLarge); + const suggestionsContainerHeight = measureHeightOfSuggestionsContainer(props.suggestions.length, props.isSuggestionPickerLarge); const [{width, left, bottom}, setContainerState] = React.useState({ width: 0, left: 0, bottom: 0, }); - const [shouldBelowContainer, setShouldBelowContainer] = React.useState(false); + const [shouldShowBelowContainer, setShouldShowBelowContainer] = React.useState(false); React.useEffect(() => { const container = containerRef.current; if (!container) { @@ -46,17 +46,17 @@ function AutoCompleteSuggestions({measureParentContainer = () => {} } measureParentContainer((x, y, w, h) => { - const currenBottom = y < suggestionContainerHeight ? windowHeight - y - suggestionContainerHeight - h : windowHeight - y; - setShouldBelowContainer(y < suggestionContainerHeight); - setContainerState({left: x, bottom: currenBottom, width: w}); + const currentBottom = y < suggestionsContainerHeight ? windowHeight - y - suggestionsContainerHeight - h : windowHeight - y; + setShouldShowBelowContainer(y < suggestionsContainerHeight); + setContainerState({left: x, bottom: currentBottom, width: w}); }); - }, [measureParentContainer, windowHeight, windowWidth, suggestionContainerHeight]); + }, [measureParentContainer, windowHeight, windowWidth, suggestionsContainerHeight]); const componentToRender = ( // eslint-disable-next-line react/jsx-props-no-spreading {...props} - shouldBeDisplayedBelowParentContainer={shouldBelowContainer} + shouldBeDisplayedBelowParentContainer={shouldShowBelowContainer} ref={containerRef} /> ); diff --git a/src/pages/home/report/ReportActionItemMessageEdit.tsx b/src/pages/home/report/ReportActionItemMessageEdit.tsx index 294f7e83d66e..85f206cb94b0 100644 --- a/src/pages/home/report/ReportActionItemMessageEdit.tsx +++ b/src/pages/home/report/ReportActionItemMessageEdit.tsx @@ -392,7 +392,6 @@ function ReportActionItemMessageEdit( } containerRef.current.measureInWindow(callback); }, - // We added isComposerFullSize in dependencies so that when this value changes, we recalculate the position of the popup // eslint-disable-next-line react-hooks/exhaustive-deps [], ); From c093a99e4fcbf720603f836be8cadca5d71217c1 Mon Sep 17 00:00:00 2001 From: dukenv0307 Date: Wed, 20 Mar 2024 14:54:49 +0700 Subject: [PATCH 15/24] fix portal on native --- src/components/AutoCompleteSuggestions/index.native.tsx | 4 +++- .../home/report/ReportActionCompose/ReportActionCompose.tsx | 2 +- src/pages/home/report/ReportActionItemMessageEdit.tsx | 2 +- 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/src/components/AutoCompleteSuggestions/index.native.tsx b/src/components/AutoCompleteSuggestions/index.native.tsx index fbfa7d953581..526954d370e3 100644 --- a/src/components/AutoCompleteSuggestions/index.native.tsx +++ b/src/components/AutoCompleteSuggestions/index.native.tsx @@ -1,11 +1,13 @@ import {Portal} from '@gorhom/portal'; import React from 'react'; +import {useSuggestionsContext} from '@pages/home/report/ReportActionCompose/ComposerWithSuggestionsEdit/SuggestionsContext'; import BaseAutoCompleteSuggestions from './BaseAutoCompleteSuggestions'; import type {AutoCompleteSuggestionsProps} from './types'; function AutoCompleteSuggestions({measureParentContainer, ...props}: AutoCompleteSuggestionsProps) { + const {activeID} = useSuggestionsContext(); return ( - + {/* eslint-disable-next-line react/jsx-props-no-spreading */} {...props} /> diff --git a/src/pages/home/report/ReportActionCompose/ReportActionCompose.tsx b/src/pages/home/report/ReportActionCompose/ReportActionCompose.tsx index 1e0e322be258..4c767eeb7966 100644 --- a/src/pages/home/report/ReportActionCompose/ReportActionCompose.tsx +++ b/src/pages/home/report/ReportActionCompose/ReportActionCompose.tsx @@ -364,7 +364,7 @@ function ReportActionCompose({ {shouldShowReportRecipientLocalTime && hasReportRecipient && } - + - + Date: Wed, 20 Mar 2024 15:02:00 +0700 Subject: [PATCH 16/24] rename --- .../ComposerWithSuggestionsEdit/SuggestionsContext.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pages/home/report/ReportActionCompose/ComposerWithSuggestionsEdit/SuggestionsContext.tsx b/src/pages/home/report/ReportActionCompose/ComposerWithSuggestionsEdit/SuggestionsContext.tsx index 36ed962010ab..ceecb56af450 100644 --- a/src/pages/home/report/ReportActionCompose/ComposerWithSuggestionsEdit/SuggestionsContext.tsx +++ b/src/pages/home/report/ReportActionCompose/ComposerWithSuggestionsEdit/SuggestionsContext.tsx @@ -51,6 +51,6 @@ function useSuggestionsContext() { return context; } -SuggestionsContextProvider.displayName = 'PlaybackContextProvider'; +SuggestionsContextProvider.displayName = 'SuggestionsContextProvider'; export {SuggestionsContextProvider, useSuggestionsContext}; From 8c961f32268e2bad5469664cbf3d8414f068c189 Mon Sep 17 00:00:00 2001 From: dukenv0307 Date: Mon, 25 Mar 2024 14:17:53 +0700 Subject: [PATCH 17/24] resolve conflict --- .../javascript/authorChecklist/index.js | 174 +- .../javascript/awaitStagingDeploys/index.js | 174 +- .../javascript/checkDeployBlockers/index.js | 174 +- .../createOrUpdateStagingDeploy.js | 11 +- .../createOrUpdateStagingDeploy/index.js | 185 +- .../javascript/getArtifactInfo/index.js | 174 +- .../getDeployPullRequestList/index.js | 174 +- .../javascript/getPullRequestDetails/index.js | 174 +- .../javascript/getReleaseBody/index.js | 174 +- .../javascript/isStagingDeployLocked/index.js | 174 +- .../markPullRequestsAsDeployed/index.js | 174 +- .../javascript/postTestBuildComment/index.js | 174 +- .../reopenIssueWithComment/index.js | 174 +- .../javascript/reviewerChecklist/index.js | 174 +- .../javascript/verifySignedCommits/index.js | 174 +- .github/libs/GithubUtils.js | 180 +- android/app/build.gradle | 4 +- .../expenses/Add-expenses-to-a-report.md | 18 + .../expenses/Export-expenses.md | 13 + .../integrations/HR-integrations/Workday.md | 2 +- ...ssign-billing-owner-and-payment-account.md | 30 + .../workspaces/Create-a-group-workspace.md | 24 + .../workspaces/Set-time-and-distance-rates.md | 24 + .../Set-up-your-individual-workspace.md | 20 + docs/redirects.csv | 1 + ios/NewExpensify/Info.plist | 4 +- ios/NewExpensifyTests/Info.plist | 4 +- ios/NotificationServiceExtension/Info.plist | 4 +- package-lock.json | 4 +- package.json | 2 +- src/CONST.ts | 19 +- src/ROUTES.ts | 30 +- src/SCREENS.ts | 1 - src/components/AddressSearch/index.tsx | 2 + src/components/AddressSearch/types.ts | 2 +- src/components/AttachmentModal.tsx | 2 +- src/components/Banner.tsx | 2 + src/components/Button/index.tsx | 2 + .../ButtonWithDropdownMenu/index.tsx | 2 +- .../ButtonWithDropdownMenu/types.ts | 1 + src/components/DistanceRequest/index.tsx | 320 -- src/components/HeaderWithBackButton/index.tsx | 5 +- src/components/MoneyReportHeader.tsx | 7 + .../MoneyRequestConfirmationList.tsx | 12 +- ...oraryForRefactorRequestConfirmationList.js | 8 +- src/components/Picker/BasePicker.tsx | 4 +- src/components/Picker/types.ts | 2 +- src/components/PopoverMenu.tsx | 4 + .../ReportActionItem/MoneyRequestView.tsx | 6 +- .../ReportActionItemImages.tsx | 74 +- .../ReportActionItem/ReportPreview.tsx | 5 + src/components/ScreenWrapper.tsx | 3 +- src/components/SettlementButton.tsx | 14 +- src/components/VideoPlayer/IconButton.js | 30 +- src/components/VideoPlayerPreview/index.tsx | 22 +- src/languages/en.ts | 3 +- src/languages/es.ts | 3 +- .../API/parameters/EnablePolicyTaxesParams.ts | 1 + .../API/parameters/GetRouteForDraftParams.ts | 6 - .../API/parameters/HoldMoneyRequestParams.ts | 1 + src/libs/API/parameters/index.ts | 1 - src/libs/API/types.ts | 2 +- src/libs/DistanceRequestUtils.ts | 3 +- src/libs/IOUUtils.ts | 6 +- .../Navigation/AppNavigator/AuthScreens.tsx | 1 + .../AppNavigator/ModalStackNavigators.tsx | 1 - src/libs/Navigation/Navigation.ts | 2 +- src/libs/Navigation/linkingConfig/config.ts | 1 - src/libs/Navigation/types.ts | 5 +- src/libs/PolicyDistanceRatesUtils.ts | 13 +- src/libs/PolicyUtils.ts | 9 +- src/libs/ReportActionsUtils.ts | 4 +- src/libs/ReportUtils.ts | 63 +- src/libs/actions/IOU.ts | 74 +- src/libs/actions/Policy.ts | 66 +- src/libs/actions/ReportActions.ts | 5 +- src/libs/actions/Transaction.ts | 21 +- src/pages/EditReportFieldDropdownPage.tsx | 97 +- src/pages/EditRequestDistancePage.js | 122 - src/pages/EditRequestPage.js | 11 - src/pages/home/ReportScreen.tsx | 70 +- .../report/AnimatedEmptyStateBackground.tsx | 9 +- .../report/ContextMenu/ContextMenuActions.tsx | 2 +- src/pages/home/report/ReportActionItem.tsx | 10 +- .../report/ReportActionItemMessageEdit.tsx | 6 +- src/pages/home/report/ReportActionsView.tsx | 6 +- src/pages/iou/HoldReasonPage.tsx | 4 +- src/pages/iou/NewDistanceRequestPage.js | 85 - .../request/IOURequestRedirectToStartPage.js | 6 +- .../step/IOURequestStepConfirmation.js | 4 +- .../request/step/IOURequestStepDistance.js | 71 +- .../request/step/IOURequestStepWaypoint.tsx | 12 +- .../ExitSurvey/ExitSurveyResponsePage.tsx | 3 + src/pages/workspace/WorkspaceProfilePage.tsx | 235 +- .../categories/WorkspaceCategoriesPage.tsx | 3 +- .../distanceRates/CreateDistanceRatePage.tsx | 6 +- .../PolicyDistanceRateEditPage.tsx | 2 +- .../distanceRates/PolicyDistanceRatesPage.tsx | 6 +- .../reimburse/WorkspaceRateAndUnitPage.tsx | 158 + .../WorkspaceRateAndUnitPage/RatePage.tsx | 2 +- .../reimburse/WorkspaceReimbursePage.js | 43 - .../reimburse/WorkspaceReimbursePage.tsx | 33 + ...ction.js => WorkspaceReimburseSection.tsx} | 52 +- ...urseView.js => WorkspaceReimburseView.tsx} | 118 +- .../workspace/tags/WorkspaceTagsPage.tsx | 3 +- src/pages/workspace/taxes/NamePage.tsx | 1 - src/pages/workspace/taxes/ValuePage.tsx | 3 + .../workflows/WorkspaceWorkflowsPage.tsx | 16 +- ...h.stories.js => AddressSearch.stories.tsx} | 22 +- .../{Banner.stories.js => Banner.stories.tsx} | 14 +- .../{Button.stories.js => Button.stories.tsx} | 20 +- ....js => ButtonWithDropdownMenu.stories.tsx} | 19 +- src/stories/HeaderWithBackButton.stories.tsx | 11 +- src/styles/index.ts | 5 +- src/styles/utils/index.ts | 2 +- src/types/onyx/OriginalMessage.ts | 6 + tests/actions/IOUTest.js | 2978 --------------- tests/actions/IOUTest.ts | 3225 +++++++++++++++++ tests/unit/GithubUtilsTest.ts | 74 +- tests/unit/ReportActionsUtilsTest.ts | 137 + tests/unit/ReportUtilsTest.js | 74 + wdyr.js => wdyr.ts | 6 +- workflow_tests/mocks/authorChecklistMocks.js | 10 - workflow_tests/mocks/authorChecklistMocks.ts | 12 + ...{cherryPickMocks.js => cherryPickMocks.ts} | 77 +- workflow_tests/mocks/claMocks.js | 25 - workflow_tests/mocks/claMocks.ts | 22 + ...rsionMocks.js => createNewVersionMocks.ts} | 45 +- ...yBlockerMocks.js => deployBlockerMocks.ts} | 30 +- workflow_tests/mocks/deployMocks.js | 55 - workflow_tests/mocks/deployMocks.ts | 58 + workflow_tests/mocks/failureNotifierMocks.js | 11 - workflow_tests/mocks/failureNotifierMocks.ts | 17 + ...cleMocks.js => finishReleaseCycleMocks.ts} | 61 +- workflow_tests/mocks/lintMocks.js | 19 - workflow_tests/mocks/lintMocks.ts | 21 + ...ockDeploysMocks.js => lockDeploysMocks.ts} | 16 +- ...mDeployMocks.js => platformDeployMocks.ts} | 168 +- workflow_tests/mocks/preDeployMocks.js | 103 - workflow_tests/mocks/preDeployMocks.ts | 94 + .../mocks/reviewerChecklistMocks.js | 9 - .../mocks/reviewerChecklistMocks.ts | 11 + .../{testBuildMocks.js => testBuildMocks.ts} | 134 +- workflow_tests/mocks/testMocks.js | 26 - workflow_tests/mocks/testMocks.ts | 24 + .../mocks/validateGithubActionsMocks.js | 23 - .../mocks/validateGithubActionsMocks.ts | 19 + workflow_tests/mocks/verifyPodfileMocks.js | 11 - workflow_tests/mocks/verifyPodfileMocks.ts | 17 + ...tsMocks.js => verifySignedCommitsMocks.ts} | 10 +- workflow_tests/utils/utils.ts | 2 +- 151 files changed, 6651 insertions(+), 5933 deletions(-) create mode 100644 docs/articles/expensify-classic/expenses/Add-expenses-to-a-report.md create mode 100644 docs/articles/expensify-classic/expenses/Export-expenses.md create mode 100644 docs/articles/expensify-classic/workspaces/Assign-billing-owner-and-payment-account.md create mode 100644 docs/articles/expensify-classic/workspaces/Create-a-group-workspace.md create mode 100644 docs/articles/expensify-classic/workspaces/Set-time-and-distance-rates.md create mode 100644 docs/articles/expensify-classic/workspaces/Set-up-your-individual-workspace.md delete mode 100644 src/components/DistanceRequest/index.tsx delete mode 100644 src/libs/API/parameters/GetRouteForDraftParams.ts delete mode 100644 src/pages/EditRequestDistancePage.js delete mode 100644 src/pages/iou/NewDistanceRequestPage.js create mode 100644 src/pages/workspace/reimburse/WorkspaceRateAndUnitPage.tsx delete mode 100644 src/pages/workspace/reimburse/WorkspaceReimbursePage.js create mode 100644 src/pages/workspace/reimburse/WorkspaceReimbursePage.tsx rename src/pages/workspace/reimburse/{WorkspaceReimburseSection.js => WorkspaceReimburseSection.tsx} (60%) rename src/pages/workspace/reimburse/{WorkspaceReimburseView.js => WorkspaceReimburseView.tsx} (56%) rename src/stories/{AddressSearch.stories.js => AddressSearch.stories.tsx} (51%) rename src/stories/{Banner.stories.js => Banner.stories.tsx} (72%) rename src/stories/{Button.stories.js => Button.stories.tsx} (79%) rename src/stories/{ButtonWithDropdownMenu.stories.js => ButtonWithDropdownMenu.stories.tsx} (51%) delete mode 100644 tests/actions/IOUTest.js create mode 100644 tests/actions/IOUTest.ts rename wdyr.js => wdyr.ts (76%) delete mode 100644 workflow_tests/mocks/authorChecklistMocks.js create mode 100644 workflow_tests/mocks/authorChecklistMocks.ts rename workflow_tests/mocks/{cherryPickMocks.js => cherryPickMocks.ts} (51%) delete mode 100644 workflow_tests/mocks/claMocks.js create mode 100644 workflow_tests/mocks/claMocks.ts rename workflow_tests/mocks/{createNewVersionMocks.js => createNewVersionMocks.ts} (62%) rename workflow_tests/mocks/{deployBlockerMocks.js => deployBlockerMocks.ts} (60%) delete mode 100644 workflow_tests/mocks/deployMocks.js create mode 100644 workflow_tests/mocks/deployMocks.ts delete mode 100644 workflow_tests/mocks/failureNotifierMocks.js create mode 100644 workflow_tests/mocks/failureNotifierMocks.ts rename workflow_tests/mocks/{finishReleaseCycleMocks.js => finishReleaseCycleMocks.ts} (84%) delete mode 100644 workflow_tests/mocks/lintMocks.js create mode 100644 workflow_tests/mocks/lintMocks.ts rename workflow_tests/mocks/{lockDeploysMocks.js => lockDeploysMocks.ts} (69%) rename workflow_tests/mocks/{platformDeployMocks.js => platformDeployMocks.ts} (54%) delete mode 100644 workflow_tests/mocks/preDeployMocks.js create mode 100644 workflow_tests/mocks/preDeployMocks.ts delete mode 100644 workflow_tests/mocks/reviewerChecklistMocks.js create mode 100644 workflow_tests/mocks/reviewerChecklistMocks.ts rename workflow_tests/mocks/{testBuildMocks.js => testBuildMocks.ts} (59%) delete mode 100644 workflow_tests/mocks/testMocks.js create mode 100644 workflow_tests/mocks/testMocks.ts delete mode 100644 workflow_tests/mocks/validateGithubActionsMocks.js create mode 100644 workflow_tests/mocks/validateGithubActionsMocks.ts delete mode 100644 workflow_tests/mocks/verifyPodfileMocks.js create mode 100644 workflow_tests/mocks/verifyPodfileMocks.ts rename workflow_tests/mocks/{verifySignedCommitsMocks.js => verifySignedCommitsMocks.ts} (63%) diff --git a/.github/actions/javascript/authorChecklist/index.js b/.github/actions/javascript/authorChecklist/index.js index 528a0a11498a..b20cc83498ba 100644 --- a/.github/actions/javascript/authorChecklist/index.js +++ b/.github/actions/javascript/authorChecklist/index.js @@ -255,7 +255,7 @@ class GithubUtils { } /** - * Generate the issue body for a StagingDeployCash. + * Generate the issue body and assignees for a StagingDeployCash. * * @param {String} tag * @param {Array} PRList - The list of PR URLs which are included in this StagingDeployCash @@ -268,7 +268,7 @@ class GithubUtils { * @param {Boolean} [isGHStatusChecked] * @returns {Promise} */ - static generateStagingDeployCashBody( + static generateStagingDeployCashBodyAndAssignees( tag, PRList, verifiedPRList = [], @@ -281,85 +281,89 @@ class GithubUtils { ) { return this.fetchAllPullRequests(_.map(PRList, this.getPullRequestNumberFromURL)) .then((data) => { - // The format of this map is following: - // { - // 'https://github.com/Expensify/App/pull/9641': [ 'PauloGasparSv', 'kidroca' ], - // 'https://github.com/Expensify/App/pull/9642': [ 'mountiny', 'kidroca' ] - // } - const internalQAPRMap = _.reduce( - _.filter(data, (pr) => !_.isEmpty(_.findWhere(pr.labels, {name: CONST.LABELS.INTERNAL_QA}))), - (map, pr) => { - // eslint-disable-next-line no-param-reassign - map[pr.html_url] = _.compact(_.pluck(pr.assignees, 'login')); - return map; - }, - {}, - ); - console.log('Found the following Internal QA PRs:', internalQAPRMap); - - const noQAPRs = _.pluck( - _.filter(data, (PR) => /\[No\s?QA]/i.test(PR.title)), - 'html_url', - ); - console.log('Found the following NO QA PRs:', noQAPRs); - const verifiedOrNoQAPRs = _.union(verifiedPRList, noQAPRs); - - const sortedPRList = _.chain(PRList).difference(_.keys(internalQAPRMap)).unique().sortBy(GithubUtils.getPullRequestNumberFromURL).value(); - const sortedDeployBlockers = _.sortBy(_.unique(deployBlockers), GithubUtils.getIssueOrPullRequestNumberFromURL); - - // Tag version and comparison URL - // eslint-disable-next-line max-len - let issueBody = `**Release Version:** \`${tag}\`\r\n**Compare Changes:** https://github.com/Expensify/App/compare/production...staging\r\n`; - - // PR list - if (!_.isEmpty(sortedPRList)) { - issueBody += '\r\n**This release contains changes from the following pull requests:**\r\n'; - _.each(sortedPRList, (URL) => { - issueBody += _.contains(verifiedOrNoQAPRs, URL) ? '- [x]' : '- [ ]'; - issueBody += ` ${URL}\r\n`; - }); - issueBody += '\r\n\r\n'; - } + const internalQAPRs = _.filter(data, (pr) => !_.isEmpty(_.findWhere(pr.labels, {name: CONST.LABELS.INTERNAL_QA}))); + return Promise.all(_.map(internalQAPRs, (pr) => this.getPullRequestMergerLogin(pr.number).then((mergerLogin) => ({url: pr.html_url, mergerLogin})))).then((results) => { + // The format of this map is following: + // { + // 'https://github.com/Expensify/App/pull/9641': 'PauloGasparSv', + // 'https://github.com/Expensify/App/pull/9642': 'mountiny' + // } + const internalQAPRMap = _.reduce( + results, + (acc, {url, mergerLogin}) => { + acc[url] = mergerLogin; + return acc; + }, + {}, + ); + console.log('Found the following Internal QA PRs:', internalQAPRMap); + + const noQAPRs = _.pluck( + _.filter(data, (PR) => /\[No\s?QA]/i.test(PR.title)), + 'html_url', + ); + console.log('Found the following NO QA PRs:', noQAPRs); + const verifiedOrNoQAPRs = _.union(verifiedPRList, noQAPRs); + + const sortedPRList = _.chain(PRList).difference(_.keys(internalQAPRMap)).unique().sortBy(GithubUtils.getPullRequestNumberFromURL).value(); + const sortedDeployBlockers = _.sortBy(_.unique(deployBlockers), GithubUtils.getIssueOrPullRequestNumberFromURL); + + // Tag version and comparison URL + // eslint-disable-next-line max-len + let issueBody = `**Release Version:** \`${tag}\`\r\n**Compare Changes:** https://github.com/Expensify/App/compare/production...staging\r\n`; + + // PR list + if (!_.isEmpty(sortedPRList)) { + issueBody += '\r\n**This release contains changes from the following pull requests:**\r\n'; + _.each(sortedPRList, (URL) => { + issueBody += _.contains(verifiedOrNoQAPRs, URL) ? '- [x]' : '- [ ]'; + issueBody += ` ${URL}\r\n`; + }); + issueBody += '\r\n\r\n'; + } - // Internal QA PR list - if (!_.isEmpty(internalQAPRMap)) { - console.log('Found the following verified Internal QA PRs:', resolvedInternalQAPRs); - issueBody += '**Internal QA:**\r\n'; - _.each(internalQAPRMap, (assignees, URL) => { - const assigneeMentions = _.reduce(assignees, (memo, assignee) => `${memo} @${assignee}`, ''); - issueBody += `${_.contains(resolvedInternalQAPRs, URL) ? '- [x]' : '- [ ]'} `; - issueBody += `${URL}`; - issueBody += ` -${assigneeMentions}`; - issueBody += '\r\n'; - }); - issueBody += '\r\n\r\n'; - } + // Internal QA PR list + if (!_.isEmpty(internalQAPRMap)) { + console.log('Found the following verified Internal QA PRs:', resolvedInternalQAPRs); + issueBody += '**Internal QA:**\r\n'; + _.each(internalQAPRMap, (merger, URL) => { + const mergerMention = `@${merger}`; + issueBody += `${_.contains(resolvedInternalQAPRs, URL) ? '- [x]' : '- [ ]'} `; + issueBody += `${URL}`; + issueBody += ` - ${mergerMention}`; + issueBody += '\r\n'; + }); + issueBody += '\r\n\r\n'; + } - // Deploy blockers - if (!_.isEmpty(deployBlockers)) { - issueBody += '**Deploy Blockers:**\r\n'; - _.each(sortedDeployBlockers, (URL) => { - issueBody += _.contains(resolvedDeployBlockers, URL) ? '- [x] ' : '- [ ] '; - issueBody += URL; - issueBody += '\r\n'; - }); - issueBody += '\r\n\r\n'; - } + // Deploy blockers + if (!_.isEmpty(deployBlockers)) { + issueBody += '**Deploy Blockers:**\r\n'; + _.each(sortedDeployBlockers, (URL) => { + issueBody += _.contains(resolvedDeployBlockers, URL) ? '- [x] ' : '- [ ] '; + issueBody += URL; + issueBody += '\r\n'; + }); + issueBody += '\r\n\r\n'; + } - issueBody += '**Deployer verifications:**'; - // eslint-disable-next-line max-len - issueBody += `\r\n- [${ - isTimingDashboardChecked ? 'x' : ' ' - }] I checked the [App Timing Dashboard](https://graphs.expensify.com/grafana/d/yj2EobAGz/app-timing?orgId=1) and verified this release does not cause a noticeable performance regression.`; - // eslint-disable-next-line max-len - issueBody += `\r\n- [${ - isFirebaseChecked ? 'x' : ' ' - }] I checked [Firebase Crashlytics](https://console.firebase.google.com/u/0/project/expensify-chat/crashlytics/app/android:com.expensify.chat/issues?state=open&time=last-seven-days&tag=all) and verified that this release does not introduce any new crashes. More detailed instructions on this verification can be found [here](https://stackoverflowteams.com/c/expensify/questions/15095/15096).`; - // eslint-disable-next-line max-len - issueBody += `\r\n- [${isGHStatusChecked ? 'x' : ' '}] I checked [GitHub Status](https://www.githubstatus.com/) and verified there is no reported incident with Actions.`; - - issueBody += '\r\n\r\ncc @Expensify/applauseleads\r\n'; - return issueBody; + issueBody += '**Deployer verifications:**'; + // eslint-disable-next-line max-len + issueBody += `\r\n- [${ + isTimingDashboardChecked ? 'x' : ' ' + }] I checked the [App Timing Dashboard](https://graphs.expensify.com/grafana/d/yj2EobAGz/app-timing?orgId=1) and verified this release does not cause a noticeable performance regression.`; + // eslint-disable-next-line max-len + issueBody += `\r\n- [${ + isFirebaseChecked ? 'x' : ' ' + }] I checked [Firebase Crashlytics](https://console.firebase.google.com/u/0/project/expensify-chat/crashlytics/app/android:com.expensify.chat/issues?state=open&time=last-seven-days&tag=all) and verified that this release does not introduce any new crashes. More detailed instructions on this verification can be found [here](https://stackoverflowteams.com/c/expensify/questions/15095/15096).`; + // eslint-disable-next-line max-len + issueBody += `\r\n- [${isGHStatusChecked ? 'x' : ' '}] I checked [GitHub Status](https://www.githubstatus.com/) and verified there is no reported incident with Actions.`; + + issueBody += '\r\n\r\ncc @Expensify/applauseleads\r\n'; + const issueAssignees = _.uniq(_.values(internalQAPRMap)); + const issue = {issueBody, issueAssignees}; + return issue; + }); }) .catch((err) => console.warn('Error generating StagingDeployCash issue body! Continuing...', err)); } @@ -393,6 +397,20 @@ class GithubUtils { .catch((err) => console.error('Failed to get PR list', err)); } + /** + * @param {Number} pullRequestNumber + * @returns {Promise} + */ + static getPullRequestMergerLogin(pullRequestNumber) { + return this.octokit.pulls + .get({ + owner: CONST.GITHUB_OWNER, + repo: CONST.APP_REPO, + pull_number: pullRequestNumber, + }) + .then(({data: pullRequest}) => pullRequest.merged_by.login); + } + /** * @param {Number} pullRequestNumber * @returns {Promise} diff --git a/.github/actions/javascript/awaitStagingDeploys/index.js b/.github/actions/javascript/awaitStagingDeploys/index.js index f042dbb38a91..6b8401a08d6d 100644 --- a/.github/actions/javascript/awaitStagingDeploys/index.js +++ b/.github/actions/javascript/awaitStagingDeploys/index.js @@ -367,7 +367,7 @@ class GithubUtils { } /** - * Generate the issue body for a StagingDeployCash. + * Generate the issue body and assignees for a StagingDeployCash. * * @param {String} tag * @param {Array} PRList - The list of PR URLs which are included in this StagingDeployCash @@ -380,7 +380,7 @@ class GithubUtils { * @param {Boolean} [isGHStatusChecked] * @returns {Promise} */ - static generateStagingDeployCashBody( + static generateStagingDeployCashBodyAndAssignees( tag, PRList, verifiedPRList = [], @@ -393,85 +393,89 @@ class GithubUtils { ) { return this.fetchAllPullRequests(_.map(PRList, this.getPullRequestNumberFromURL)) .then((data) => { - // The format of this map is following: - // { - // 'https://github.com/Expensify/App/pull/9641': [ 'PauloGasparSv', 'kidroca' ], - // 'https://github.com/Expensify/App/pull/9642': [ 'mountiny', 'kidroca' ] - // } - const internalQAPRMap = _.reduce( - _.filter(data, (pr) => !_.isEmpty(_.findWhere(pr.labels, {name: CONST.LABELS.INTERNAL_QA}))), - (map, pr) => { - // eslint-disable-next-line no-param-reassign - map[pr.html_url] = _.compact(_.pluck(pr.assignees, 'login')); - return map; - }, - {}, - ); - console.log('Found the following Internal QA PRs:', internalQAPRMap); - - const noQAPRs = _.pluck( - _.filter(data, (PR) => /\[No\s?QA]/i.test(PR.title)), - 'html_url', - ); - console.log('Found the following NO QA PRs:', noQAPRs); - const verifiedOrNoQAPRs = _.union(verifiedPRList, noQAPRs); - - const sortedPRList = _.chain(PRList).difference(_.keys(internalQAPRMap)).unique().sortBy(GithubUtils.getPullRequestNumberFromURL).value(); - const sortedDeployBlockers = _.sortBy(_.unique(deployBlockers), GithubUtils.getIssueOrPullRequestNumberFromURL); - - // Tag version and comparison URL - // eslint-disable-next-line max-len - let issueBody = `**Release Version:** \`${tag}\`\r\n**Compare Changes:** https://github.com/Expensify/App/compare/production...staging\r\n`; - - // PR list - if (!_.isEmpty(sortedPRList)) { - issueBody += '\r\n**This release contains changes from the following pull requests:**\r\n'; - _.each(sortedPRList, (URL) => { - issueBody += _.contains(verifiedOrNoQAPRs, URL) ? '- [x]' : '- [ ]'; - issueBody += ` ${URL}\r\n`; - }); - issueBody += '\r\n\r\n'; - } + const internalQAPRs = _.filter(data, (pr) => !_.isEmpty(_.findWhere(pr.labels, {name: CONST.LABELS.INTERNAL_QA}))); + return Promise.all(_.map(internalQAPRs, (pr) => this.getPullRequestMergerLogin(pr.number).then((mergerLogin) => ({url: pr.html_url, mergerLogin})))).then((results) => { + // The format of this map is following: + // { + // 'https://github.com/Expensify/App/pull/9641': 'PauloGasparSv', + // 'https://github.com/Expensify/App/pull/9642': 'mountiny' + // } + const internalQAPRMap = _.reduce( + results, + (acc, {url, mergerLogin}) => { + acc[url] = mergerLogin; + return acc; + }, + {}, + ); + console.log('Found the following Internal QA PRs:', internalQAPRMap); + + const noQAPRs = _.pluck( + _.filter(data, (PR) => /\[No\s?QA]/i.test(PR.title)), + 'html_url', + ); + console.log('Found the following NO QA PRs:', noQAPRs); + const verifiedOrNoQAPRs = _.union(verifiedPRList, noQAPRs); + + const sortedPRList = _.chain(PRList).difference(_.keys(internalQAPRMap)).unique().sortBy(GithubUtils.getPullRequestNumberFromURL).value(); + const sortedDeployBlockers = _.sortBy(_.unique(deployBlockers), GithubUtils.getIssueOrPullRequestNumberFromURL); + + // Tag version and comparison URL + // eslint-disable-next-line max-len + let issueBody = `**Release Version:** \`${tag}\`\r\n**Compare Changes:** https://github.com/Expensify/App/compare/production...staging\r\n`; + + // PR list + if (!_.isEmpty(sortedPRList)) { + issueBody += '\r\n**This release contains changes from the following pull requests:**\r\n'; + _.each(sortedPRList, (URL) => { + issueBody += _.contains(verifiedOrNoQAPRs, URL) ? '- [x]' : '- [ ]'; + issueBody += ` ${URL}\r\n`; + }); + issueBody += '\r\n\r\n'; + } - // Internal QA PR list - if (!_.isEmpty(internalQAPRMap)) { - console.log('Found the following verified Internal QA PRs:', resolvedInternalQAPRs); - issueBody += '**Internal QA:**\r\n'; - _.each(internalQAPRMap, (assignees, URL) => { - const assigneeMentions = _.reduce(assignees, (memo, assignee) => `${memo} @${assignee}`, ''); - issueBody += `${_.contains(resolvedInternalQAPRs, URL) ? '- [x]' : '- [ ]'} `; - issueBody += `${URL}`; - issueBody += ` -${assigneeMentions}`; - issueBody += '\r\n'; - }); - issueBody += '\r\n\r\n'; - } + // Internal QA PR list + if (!_.isEmpty(internalQAPRMap)) { + console.log('Found the following verified Internal QA PRs:', resolvedInternalQAPRs); + issueBody += '**Internal QA:**\r\n'; + _.each(internalQAPRMap, (merger, URL) => { + const mergerMention = `@${merger}`; + issueBody += `${_.contains(resolvedInternalQAPRs, URL) ? '- [x]' : '- [ ]'} `; + issueBody += `${URL}`; + issueBody += ` - ${mergerMention}`; + issueBody += '\r\n'; + }); + issueBody += '\r\n\r\n'; + } - // Deploy blockers - if (!_.isEmpty(deployBlockers)) { - issueBody += '**Deploy Blockers:**\r\n'; - _.each(sortedDeployBlockers, (URL) => { - issueBody += _.contains(resolvedDeployBlockers, URL) ? '- [x] ' : '- [ ] '; - issueBody += URL; - issueBody += '\r\n'; - }); - issueBody += '\r\n\r\n'; - } + // Deploy blockers + if (!_.isEmpty(deployBlockers)) { + issueBody += '**Deploy Blockers:**\r\n'; + _.each(sortedDeployBlockers, (URL) => { + issueBody += _.contains(resolvedDeployBlockers, URL) ? '- [x] ' : '- [ ] '; + issueBody += URL; + issueBody += '\r\n'; + }); + issueBody += '\r\n\r\n'; + } - issueBody += '**Deployer verifications:**'; - // eslint-disable-next-line max-len - issueBody += `\r\n- [${ - isTimingDashboardChecked ? 'x' : ' ' - }] I checked the [App Timing Dashboard](https://graphs.expensify.com/grafana/d/yj2EobAGz/app-timing?orgId=1) and verified this release does not cause a noticeable performance regression.`; - // eslint-disable-next-line max-len - issueBody += `\r\n- [${ - isFirebaseChecked ? 'x' : ' ' - }] I checked [Firebase Crashlytics](https://console.firebase.google.com/u/0/project/expensify-chat/crashlytics/app/android:com.expensify.chat/issues?state=open&time=last-seven-days&tag=all) and verified that this release does not introduce any new crashes. More detailed instructions on this verification can be found [here](https://stackoverflowteams.com/c/expensify/questions/15095/15096).`; - // eslint-disable-next-line max-len - issueBody += `\r\n- [${isGHStatusChecked ? 'x' : ' '}] I checked [GitHub Status](https://www.githubstatus.com/) and verified there is no reported incident with Actions.`; - - issueBody += '\r\n\r\ncc @Expensify/applauseleads\r\n'; - return issueBody; + issueBody += '**Deployer verifications:**'; + // eslint-disable-next-line max-len + issueBody += `\r\n- [${ + isTimingDashboardChecked ? 'x' : ' ' + }] I checked the [App Timing Dashboard](https://graphs.expensify.com/grafana/d/yj2EobAGz/app-timing?orgId=1) and verified this release does not cause a noticeable performance regression.`; + // eslint-disable-next-line max-len + issueBody += `\r\n- [${ + isFirebaseChecked ? 'x' : ' ' + }] I checked [Firebase Crashlytics](https://console.firebase.google.com/u/0/project/expensify-chat/crashlytics/app/android:com.expensify.chat/issues?state=open&time=last-seven-days&tag=all) and verified that this release does not introduce any new crashes. More detailed instructions on this verification can be found [here](https://stackoverflowteams.com/c/expensify/questions/15095/15096).`; + // eslint-disable-next-line max-len + issueBody += `\r\n- [${isGHStatusChecked ? 'x' : ' '}] I checked [GitHub Status](https://www.githubstatus.com/) and verified there is no reported incident with Actions.`; + + issueBody += '\r\n\r\ncc @Expensify/applauseleads\r\n'; + const issueAssignees = _.uniq(_.values(internalQAPRMap)); + const issue = {issueBody, issueAssignees}; + return issue; + }); }) .catch((err) => console.warn('Error generating StagingDeployCash issue body! Continuing...', err)); } @@ -505,6 +509,20 @@ class GithubUtils { .catch((err) => console.error('Failed to get PR list', err)); } + /** + * @param {Number} pullRequestNumber + * @returns {Promise} + */ + static getPullRequestMergerLogin(pullRequestNumber) { + return this.octokit.pulls + .get({ + owner: CONST.GITHUB_OWNER, + repo: CONST.APP_REPO, + pull_number: pullRequestNumber, + }) + .then(({data: pullRequest}) => pullRequest.merged_by.login); + } + /** * @param {Number} pullRequestNumber * @returns {Promise} diff --git a/.github/actions/javascript/checkDeployBlockers/index.js b/.github/actions/javascript/checkDeployBlockers/index.js index 8e10f8b1d8b6..dffc089ea5c5 100644 --- a/.github/actions/javascript/checkDeployBlockers/index.js +++ b/.github/actions/javascript/checkDeployBlockers/index.js @@ -334,7 +334,7 @@ class GithubUtils { } /** - * Generate the issue body for a StagingDeployCash. + * Generate the issue body and assignees for a StagingDeployCash. * * @param {String} tag * @param {Array} PRList - The list of PR URLs which are included in this StagingDeployCash @@ -347,7 +347,7 @@ class GithubUtils { * @param {Boolean} [isGHStatusChecked] * @returns {Promise} */ - static generateStagingDeployCashBody( + static generateStagingDeployCashBodyAndAssignees( tag, PRList, verifiedPRList = [], @@ -360,85 +360,89 @@ class GithubUtils { ) { return this.fetchAllPullRequests(_.map(PRList, this.getPullRequestNumberFromURL)) .then((data) => { - // The format of this map is following: - // { - // 'https://github.com/Expensify/App/pull/9641': [ 'PauloGasparSv', 'kidroca' ], - // 'https://github.com/Expensify/App/pull/9642': [ 'mountiny', 'kidroca' ] - // } - const internalQAPRMap = _.reduce( - _.filter(data, (pr) => !_.isEmpty(_.findWhere(pr.labels, {name: CONST.LABELS.INTERNAL_QA}))), - (map, pr) => { - // eslint-disable-next-line no-param-reassign - map[pr.html_url] = _.compact(_.pluck(pr.assignees, 'login')); - return map; - }, - {}, - ); - console.log('Found the following Internal QA PRs:', internalQAPRMap); - - const noQAPRs = _.pluck( - _.filter(data, (PR) => /\[No\s?QA]/i.test(PR.title)), - 'html_url', - ); - console.log('Found the following NO QA PRs:', noQAPRs); - const verifiedOrNoQAPRs = _.union(verifiedPRList, noQAPRs); - - const sortedPRList = _.chain(PRList).difference(_.keys(internalQAPRMap)).unique().sortBy(GithubUtils.getPullRequestNumberFromURL).value(); - const sortedDeployBlockers = _.sortBy(_.unique(deployBlockers), GithubUtils.getIssueOrPullRequestNumberFromURL); - - // Tag version and comparison URL - // eslint-disable-next-line max-len - let issueBody = `**Release Version:** \`${tag}\`\r\n**Compare Changes:** https://github.com/Expensify/App/compare/production...staging\r\n`; - - // PR list - if (!_.isEmpty(sortedPRList)) { - issueBody += '\r\n**This release contains changes from the following pull requests:**\r\n'; - _.each(sortedPRList, (URL) => { - issueBody += _.contains(verifiedOrNoQAPRs, URL) ? '- [x]' : '- [ ]'; - issueBody += ` ${URL}\r\n`; - }); - issueBody += '\r\n\r\n'; - } + const internalQAPRs = _.filter(data, (pr) => !_.isEmpty(_.findWhere(pr.labels, {name: CONST.LABELS.INTERNAL_QA}))); + return Promise.all(_.map(internalQAPRs, (pr) => this.getPullRequestMergerLogin(pr.number).then((mergerLogin) => ({url: pr.html_url, mergerLogin})))).then((results) => { + // The format of this map is following: + // { + // 'https://github.com/Expensify/App/pull/9641': 'PauloGasparSv', + // 'https://github.com/Expensify/App/pull/9642': 'mountiny' + // } + const internalQAPRMap = _.reduce( + results, + (acc, {url, mergerLogin}) => { + acc[url] = mergerLogin; + return acc; + }, + {}, + ); + console.log('Found the following Internal QA PRs:', internalQAPRMap); + + const noQAPRs = _.pluck( + _.filter(data, (PR) => /\[No\s?QA]/i.test(PR.title)), + 'html_url', + ); + console.log('Found the following NO QA PRs:', noQAPRs); + const verifiedOrNoQAPRs = _.union(verifiedPRList, noQAPRs); + + const sortedPRList = _.chain(PRList).difference(_.keys(internalQAPRMap)).unique().sortBy(GithubUtils.getPullRequestNumberFromURL).value(); + const sortedDeployBlockers = _.sortBy(_.unique(deployBlockers), GithubUtils.getIssueOrPullRequestNumberFromURL); + + // Tag version and comparison URL + // eslint-disable-next-line max-len + let issueBody = `**Release Version:** \`${tag}\`\r\n**Compare Changes:** https://github.com/Expensify/App/compare/production...staging\r\n`; + + // PR list + if (!_.isEmpty(sortedPRList)) { + issueBody += '\r\n**This release contains changes from the following pull requests:**\r\n'; + _.each(sortedPRList, (URL) => { + issueBody += _.contains(verifiedOrNoQAPRs, URL) ? '- [x]' : '- [ ]'; + issueBody += ` ${URL}\r\n`; + }); + issueBody += '\r\n\r\n'; + } - // Internal QA PR list - if (!_.isEmpty(internalQAPRMap)) { - console.log('Found the following verified Internal QA PRs:', resolvedInternalQAPRs); - issueBody += '**Internal QA:**\r\n'; - _.each(internalQAPRMap, (assignees, URL) => { - const assigneeMentions = _.reduce(assignees, (memo, assignee) => `${memo} @${assignee}`, ''); - issueBody += `${_.contains(resolvedInternalQAPRs, URL) ? '- [x]' : '- [ ]'} `; - issueBody += `${URL}`; - issueBody += ` -${assigneeMentions}`; - issueBody += '\r\n'; - }); - issueBody += '\r\n\r\n'; - } + // Internal QA PR list + if (!_.isEmpty(internalQAPRMap)) { + console.log('Found the following verified Internal QA PRs:', resolvedInternalQAPRs); + issueBody += '**Internal QA:**\r\n'; + _.each(internalQAPRMap, (merger, URL) => { + const mergerMention = `@${merger}`; + issueBody += `${_.contains(resolvedInternalQAPRs, URL) ? '- [x]' : '- [ ]'} `; + issueBody += `${URL}`; + issueBody += ` - ${mergerMention}`; + issueBody += '\r\n'; + }); + issueBody += '\r\n\r\n'; + } - // Deploy blockers - if (!_.isEmpty(deployBlockers)) { - issueBody += '**Deploy Blockers:**\r\n'; - _.each(sortedDeployBlockers, (URL) => { - issueBody += _.contains(resolvedDeployBlockers, URL) ? '- [x] ' : '- [ ] '; - issueBody += URL; - issueBody += '\r\n'; - }); - issueBody += '\r\n\r\n'; - } + // Deploy blockers + if (!_.isEmpty(deployBlockers)) { + issueBody += '**Deploy Blockers:**\r\n'; + _.each(sortedDeployBlockers, (URL) => { + issueBody += _.contains(resolvedDeployBlockers, URL) ? '- [x] ' : '- [ ] '; + issueBody += URL; + issueBody += '\r\n'; + }); + issueBody += '\r\n\r\n'; + } - issueBody += '**Deployer verifications:**'; - // eslint-disable-next-line max-len - issueBody += `\r\n- [${ - isTimingDashboardChecked ? 'x' : ' ' - }] I checked the [App Timing Dashboard](https://graphs.expensify.com/grafana/d/yj2EobAGz/app-timing?orgId=1) and verified this release does not cause a noticeable performance regression.`; - // eslint-disable-next-line max-len - issueBody += `\r\n- [${ - isFirebaseChecked ? 'x' : ' ' - }] I checked [Firebase Crashlytics](https://console.firebase.google.com/u/0/project/expensify-chat/crashlytics/app/android:com.expensify.chat/issues?state=open&time=last-seven-days&tag=all) and verified that this release does not introduce any new crashes. More detailed instructions on this verification can be found [here](https://stackoverflowteams.com/c/expensify/questions/15095/15096).`; - // eslint-disable-next-line max-len - issueBody += `\r\n- [${isGHStatusChecked ? 'x' : ' '}] I checked [GitHub Status](https://www.githubstatus.com/) and verified there is no reported incident with Actions.`; - - issueBody += '\r\n\r\ncc @Expensify/applauseleads\r\n'; - return issueBody; + issueBody += '**Deployer verifications:**'; + // eslint-disable-next-line max-len + issueBody += `\r\n- [${ + isTimingDashboardChecked ? 'x' : ' ' + }] I checked the [App Timing Dashboard](https://graphs.expensify.com/grafana/d/yj2EobAGz/app-timing?orgId=1) and verified this release does not cause a noticeable performance regression.`; + // eslint-disable-next-line max-len + issueBody += `\r\n- [${ + isFirebaseChecked ? 'x' : ' ' + }] I checked [Firebase Crashlytics](https://console.firebase.google.com/u/0/project/expensify-chat/crashlytics/app/android:com.expensify.chat/issues?state=open&time=last-seven-days&tag=all) and verified that this release does not introduce any new crashes. More detailed instructions on this verification can be found [here](https://stackoverflowteams.com/c/expensify/questions/15095/15096).`; + // eslint-disable-next-line max-len + issueBody += `\r\n- [${isGHStatusChecked ? 'x' : ' '}] I checked [GitHub Status](https://www.githubstatus.com/) and verified there is no reported incident with Actions.`; + + issueBody += '\r\n\r\ncc @Expensify/applauseleads\r\n'; + const issueAssignees = _.uniq(_.values(internalQAPRMap)); + const issue = {issueBody, issueAssignees}; + return issue; + }); }) .catch((err) => console.warn('Error generating StagingDeployCash issue body! Continuing...', err)); } @@ -472,6 +476,20 @@ class GithubUtils { .catch((err) => console.error('Failed to get PR list', err)); } + /** + * @param {Number} pullRequestNumber + * @returns {Promise} + */ + static getPullRequestMergerLogin(pullRequestNumber) { + return this.octokit.pulls + .get({ + owner: CONST.GITHUB_OWNER, + repo: CONST.APP_REPO, + pull_number: pullRequestNumber, + }) + .then(({data: pullRequest}) => pullRequest.merged_by.login); + } + /** * @param {Number} pullRequestNumber * @returns {Promise} diff --git a/.github/actions/javascript/createOrUpdateStagingDeploy/createOrUpdateStagingDeploy.js b/.github/actions/javascript/createOrUpdateStagingDeploy/createOrUpdateStagingDeploy.js index 4441348a3c36..63398614fd11 100644 --- a/.github/actions/javascript/createOrUpdateStagingDeploy/createOrUpdateStagingDeploy.js +++ b/.github/actions/javascript/createOrUpdateStagingDeploy/createOrUpdateStagingDeploy.js @@ -40,8 +40,11 @@ async function run() { // Next, we generate the checklist body let checklistBody = ''; + let checklistAssignees = []; if (shouldCreateNewDeployChecklist) { - checklistBody = await GithubUtils.generateStagingDeployCashBody(newVersionTag, _.map(mergedPRs, GithubUtils.getPullRequestURLFromNumber)); + const {issueBody, issueAssignees} = await GithubUtils.generateStagingDeployCashBodyAndAssignees(newVersionTag, _.map(mergedPRs, GithubUtils.getPullRequestURLFromNumber)); + checklistBody = issueBody; + checklistAssignees = issueAssignees; } else { // Generate the updated PR list, preserving the previous state of `isVerified` for existing PRs const PRList = _.reduce( @@ -94,7 +97,7 @@ async function run() { } const didVersionChange = newVersionTag !== currentChecklistData.tag; - checklistBody = await GithubUtils.generateStagingDeployCashBody( + const {issueBody, issueAssignees} = await GithubUtils.generateStagingDeployCashBodyAndAssignees( newVersionTag, _.pluck(PRList, 'url'), _.pluck(_.where(PRList, {isVerified: true}), 'url'), @@ -105,6 +108,8 @@ async function run() { didVersionChange ? false : currentChecklistData.isFirebaseChecked, didVersionChange ? false : currentChecklistData.isGHStatusChecked, ); + checklistBody = issueBody; + checklistAssignees = issueAssignees; } // Finally, create or update the checklist @@ -119,7 +124,7 @@ async function run() { ...defaultPayload, title: `Deploy Checklist: New Expensify ${format(new Date(), CONST.DATE_FORMAT_STRING)}`, labels: [CONST.LABELS.STAGING_DEPLOY], - assignees: [CONST.APPLAUSE_BOT], + assignees: [CONST.APPLAUSE_BOT].concat(checklistAssignees), }); console.log(`Successfully created new StagingDeployCash! 🎉 ${newChecklist.html_url}`); return newChecklist; diff --git a/.github/actions/javascript/createOrUpdateStagingDeploy/index.js b/.github/actions/javascript/createOrUpdateStagingDeploy/index.js index 154dacbdc3c3..60ec0b9f0ae3 100644 --- a/.github/actions/javascript/createOrUpdateStagingDeploy/index.js +++ b/.github/actions/javascript/createOrUpdateStagingDeploy/index.js @@ -49,8 +49,11 @@ async function run() { // Next, we generate the checklist body let checklistBody = ''; + let checklistAssignees = []; if (shouldCreateNewDeployChecklist) { - checklistBody = await GithubUtils.generateStagingDeployCashBody(newVersionTag, _.map(mergedPRs, GithubUtils.getPullRequestURLFromNumber)); + const {issueBody, issueAssignees} = await GithubUtils.generateStagingDeployCashBodyAndAssignees(newVersionTag, _.map(mergedPRs, GithubUtils.getPullRequestURLFromNumber)); + checklistBody = issueBody; + checklistAssignees = issueAssignees; } else { // Generate the updated PR list, preserving the previous state of `isVerified` for existing PRs const PRList = _.reduce( @@ -103,7 +106,7 @@ async function run() { } const didVersionChange = newVersionTag !== currentChecklistData.tag; - checklistBody = await GithubUtils.generateStagingDeployCashBody( + const {issueBody, issueAssignees} = await GithubUtils.generateStagingDeployCashBodyAndAssignees( newVersionTag, _.pluck(PRList, 'url'), _.pluck(_.where(PRList, {isVerified: true}), 'url'), @@ -114,6 +117,8 @@ async function run() { didVersionChange ? false : currentChecklistData.isFirebaseChecked, didVersionChange ? false : currentChecklistData.isGHStatusChecked, ); + checklistBody = issueBody; + checklistAssignees = issueAssignees; } // Finally, create or update the checklist @@ -128,7 +133,7 @@ async function run() { ...defaultPayload, title: `Deploy Checklist: New Expensify ${format(new Date(), CONST.DATE_FORMAT_STRING)}`, labels: [CONST.LABELS.STAGING_DEPLOY], - assignees: [CONST.APPLAUSE_BOT], + assignees: [CONST.APPLAUSE_BOT].concat(checklistAssignees), }); console.log(`Successfully created new StagingDeployCash! 🎉 ${newChecklist.html_url}`); return newChecklist; @@ -406,7 +411,7 @@ class GithubUtils { } /** - * Generate the issue body for a StagingDeployCash. + * Generate the issue body and assignees for a StagingDeployCash. * * @param {String} tag * @param {Array} PRList - The list of PR URLs which are included in this StagingDeployCash @@ -419,7 +424,7 @@ class GithubUtils { * @param {Boolean} [isGHStatusChecked] * @returns {Promise} */ - static generateStagingDeployCashBody( + static generateStagingDeployCashBodyAndAssignees( tag, PRList, verifiedPRList = [], @@ -432,85 +437,89 @@ class GithubUtils { ) { return this.fetchAllPullRequests(_.map(PRList, this.getPullRequestNumberFromURL)) .then((data) => { - // The format of this map is following: - // { - // 'https://github.com/Expensify/App/pull/9641': [ 'PauloGasparSv', 'kidroca' ], - // 'https://github.com/Expensify/App/pull/9642': [ 'mountiny', 'kidroca' ] - // } - const internalQAPRMap = _.reduce( - _.filter(data, (pr) => !_.isEmpty(_.findWhere(pr.labels, {name: CONST.LABELS.INTERNAL_QA}))), - (map, pr) => { - // eslint-disable-next-line no-param-reassign - map[pr.html_url] = _.compact(_.pluck(pr.assignees, 'login')); - return map; - }, - {}, - ); - console.log('Found the following Internal QA PRs:', internalQAPRMap); - - const noQAPRs = _.pluck( - _.filter(data, (PR) => /\[No\s?QA]/i.test(PR.title)), - 'html_url', - ); - console.log('Found the following NO QA PRs:', noQAPRs); - const verifiedOrNoQAPRs = _.union(verifiedPRList, noQAPRs); - - const sortedPRList = _.chain(PRList).difference(_.keys(internalQAPRMap)).unique().sortBy(GithubUtils.getPullRequestNumberFromURL).value(); - const sortedDeployBlockers = _.sortBy(_.unique(deployBlockers), GithubUtils.getIssueOrPullRequestNumberFromURL); - - // Tag version and comparison URL - // eslint-disable-next-line max-len - let issueBody = `**Release Version:** \`${tag}\`\r\n**Compare Changes:** https://github.com/Expensify/App/compare/production...staging\r\n`; - - // PR list - if (!_.isEmpty(sortedPRList)) { - issueBody += '\r\n**This release contains changes from the following pull requests:**\r\n'; - _.each(sortedPRList, (URL) => { - issueBody += _.contains(verifiedOrNoQAPRs, URL) ? '- [x]' : '- [ ]'; - issueBody += ` ${URL}\r\n`; - }); - issueBody += '\r\n\r\n'; - } + const internalQAPRs = _.filter(data, (pr) => !_.isEmpty(_.findWhere(pr.labels, {name: CONST.LABELS.INTERNAL_QA}))); + return Promise.all(_.map(internalQAPRs, (pr) => this.getPullRequestMergerLogin(pr.number).then((mergerLogin) => ({url: pr.html_url, mergerLogin})))).then((results) => { + // The format of this map is following: + // { + // 'https://github.com/Expensify/App/pull/9641': 'PauloGasparSv', + // 'https://github.com/Expensify/App/pull/9642': 'mountiny' + // } + const internalQAPRMap = _.reduce( + results, + (acc, {url, mergerLogin}) => { + acc[url] = mergerLogin; + return acc; + }, + {}, + ); + console.log('Found the following Internal QA PRs:', internalQAPRMap); + + const noQAPRs = _.pluck( + _.filter(data, (PR) => /\[No\s?QA]/i.test(PR.title)), + 'html_url', + ); + console.log('Found the following NO QA PRs:', noQAPRs); + const verifiedOrNoQAPRs = _.union(verifiedPRList, noQAPRs); + + const sortedPRList = _.chain(PRList).difference(_.keys(internalQAPRMap)).unique().sortBy(GithubUtils.getPullRequestNumberFromURL).value(); + const sortedDeployBlockers = _.sortBy(_.unique(deployBlockers), GithubUtils.getIssueOrPullRequestNumberFromURL); + + // Tag version and comparison URL + // eslint-disable-next-line max-len + let issueBody = `**Release Version:** \`${tag}\`\r\n**Compare Changes:** https://github.com/Expensify/App/compare/production...staging\r\n`; + + // PR list + if (!_.isEmpty(sortedPRList)) { + issueBody += '\r\n**This release contains changes from the following pull requests:**\r\n'; + _.each(sortedPRList, (URL) => { + issueBody += _.contains(verifiedOrNoQAPRs, URL) ? '- [x]' : '- [ ]'; + issueBody += ` ${URL}\r\n`; + }); + issueBody += '\r\n\r\n'; + } - // Internal QA PR list - if (!_.isEmpty(internalQAPRMap)) { - console.log('Found the following verified Internal QA PRs:', resolvedInternalQAPRs); - issueBody += '**Internal QA:**\r\n'; - _.each(internalQAPRMap, (assignees, URL) => { - const assigneeMentions = _.reduce(assignees, (memo, assignee) => `${memo} @${assignee}`, ''); - issueBody += `${_.contains(resolvedInternalQAPRs, URL) ? '- [x]' : '- [ ]'} `; - issueBody += `${URL}`; - issueBody += ` -${assigneeMentions}`; - issueBody += '\r\n'; - }); - issueBody += '\r\n\r\n'; - } + // Internal QA PR list + if (!_.isEmpty(internalQAPRMap)) { + console.log('Found the following verified Internal QA PRs:', resolvedInternalQAPRs); + issueBody += '**Internal QA:**\r\n'; + _.each(internalQAPRMap, (merger, URL) => { + const mergerMention = `@${merger}`; + issueBody += `${_.contains(resolvedInternalQAPRs, URL) ? '- [x]' : '- [ ]'} `; + issueBody += `${URL}`; + issueBody += ` - ${mergerMention}`; + issueBody += '\r\n'; + }); + issueBody += '\r\n\r\n'; + } - // Deploy blockers - if (!_.isEmpty(deployBlockers)) { - issueBody += '**Deploy Blockers:**\r\n'; - _.each(sortedDeployBlockers, (URL) => { - issueBody += _.contains(resolvedDeployBlockers, URL) ? '- [x] ' : '- [ ] '; - issueBody += URL; - issueBody += '\r\n'; - }); - issueBody += '\r\n\r\n'; - } + // Deploy blockers + if (!_.isEmpty(deployBlockers)) { + issueBody += '**Deploy Blockers:**\r\n'; + _.each(sortedDeployBlockers, (URL) => { + issueBody += _.contains(resolvedDeployBlockers, URL) ? '- [x] ' : '- [ ] '; + issueBody += URL; + issueBody += '\r\n'; + }); + issueBody += '\r\n\r\n'; + } - issueBody += '**Deployer verifications:**'; - // eslint-disable-next-line max-len - issueBody += `\r\n- [${ - isTimingDashboardChecked ? 'x' : ' ' - }] I checked the [App Timing Dashboard](https://graphs.expensify.com/grafana/d/yj2EobAGz/app-timing?orgId=1) and verified this release does not cause a noticeable performance regression.`; - // eslint-disable-next-line max-len - issueBody += `\r\n- [${ - isFirebaseChecked ? 'x' : ' ' - }] I checked [Firebase Crashlytics](https://console.firebase.google.com/u/0/project/expensify-chat/crashlytics/app/android:com.expensify.chat/issues?state=open&time=last-seven-days&tag=all) and verified that this release does not introduce any new crashes. More detailed instructions on this verification can be found [here](https://stackoverflowteams.com/c/expensify/questions/15095/15096).`; - // eslint-disable-next-line max-len - issueBody += `\r\n- [${isGHStatusChecked ? 'x' : ' '}] I checked [GitHub Status](https://www.githubstatus.com/) and verified there is no reported incident with Actions.`; - - issueBody += '\r\n\r\ncc @Expensify/applauseleads\r\n'; - return issueBody; + issueBody += '**Deployer verifications:**'; + // eslint-disable-next-line max-len + issueBody += `\r\n- [${ + isTimingDashboardChecked ? 'x' : ' ' + }] I checked the [App Timing Dashboard](https://graphs.expensify.com/grafana/d/yj2EobAGz/app-timing?orgId=1) and verified this release does not cause a noticeable performance regression.`; + // eslint-disable-next-line max-len + issueBody += `\r\n- [${ + isFirebaseChecked ? 'x' : ' ' + }] I checked [Firebase Crashlytics](https://console.firebase.google.com/u/0/project/expensify-chat/crashlytics/app/android:com.expensify.chat/issues?state=open&time=last-seven-days&tag=all) and verified that this release does not introduce any new crashes. More detailed instructions on this verification can be found [here](https://stackoverflowteams.com/c/expensify/questions/15095/15096).`; + // eslint-disable-next-line max-len + issueBody += `\r\n- [${isGHStatusChecked ? 'x' : ' '}] I checked [GitHub Status](https://www.githubstatus.com/) and verified there is no reported incident with Actions.`; + + issueBody += '\r\n\r\ncc @Expensify/applauseleads\r\n'; + const issueAssignees = _.uniq(_.values(internalQAPRMap)); + const issue = {issueBody, issueAssignees}; + return issue; + }); }) .catch((err) => console.warn('Error generating StagingDeployCash issue body! Continuing...', err)); } @@ -544,6 +553,20 @@ class GithubUtils { .catch((err) => console.error('Failed to get PR list', err)); } + /** + * @param {Number} pullRequestNumber + * @returns {Promise} + */ + static getPullRequestMergerLogin(pullRequestNumber) { + return this.octokit.pulls + .get({ + owner: CONST.GITHUB_OWNER, + repo: CONST.APP_REPO, + pull_number: pullRequestNumber, + }) + .then(({data: pullRequest}) => pullRequest.merged_by.login); + } + /** * @param {Number} pullRequestNumber * @returns {Promise} diff --git a/.github/actions/javascript/getArtifactInfo/index.js b/.github/actions/javascript/getArtifactInfo/index.js index ea56ff5f4ebd..b8cb062feba5 100644 --- a/.github/actions/javascript/getArtifactInfo/index.js +++ b/.github/actions/javascript/getArtifactInfo/index.js @@ -293,7 +293,7 @@ class GithubUtils { } /** - * Generate the issue body for a StagingDeployCash. + * Generate the issue body and assignees for a StagingDeployCash. * * @param {String} tag * @param {Array} PRList - The list of PR URLs which are included in this StagingDeployCash @@ -306,7 +306,7 @@ class GithubUtils { * @param {Boolean} [isGHStatusChecked] * @returns {Promise} */ - static generateStagingDeployCashBody( + static generateStagingDeployCashBodyAndAssignees( tag, PRList, verifiedPRList = [], @@ -319,85 +319,89 @@ class GithubUtils { ) { return this.fetchAllPullRequests(_.map(PRList, this.getPullRequestNumberFromURL)) .then((data) => { - // The format of this map is following: - // { - // 'https://github.com/Expensify/App/pull/9641': [ 'PauloGasparSv', 'kidroca' ], - // 'https://github.com/Expensify/App/pull/9642': [ 'mountiny', 'kidroca' ] - // } - const internalQAPRMap = _.reduce( - _.filter(data, (pr) => !_.isEmpty(_.findWhere(pr.labels, {name: CONST.LABELS.INTERNAL_QA}))), - (map, pr) => { - // eslint-disable-next-line no-param-reassign - map[pr.html_url] = _.compact(_.pluck(pr.assignees, 'login')); - return map; - }, - {}, - ); - console.log('Found the following Internal QA PRs:', internalQAPRMap); - - const noQAPRs = _.pluck( - _.filter(data, (PR) => /\[No\s?QA]/i.test(PR.title)), - 'html_url', - ); - console.log('Found the following NO QA PRs:', noQAPRs); - const verifiedOrNoQAPRs = _.union(verifiedPRList, noQAPRs); - - const sortedPRList = _.chain(PRList).difference(_.keys(internalQAPRMap)).unique().sortBy(GithubUtils.getPullRequestNumberFromURL).value(); - const sortedDeployBlockers = _.sortBy(_.unique(deployBlockers), GithubUtils.getIssueOrPullRequestNumberFromURL); - - // Tag version and comparison URL - // eslint-disable-next-line max-len - let issueBody = `**Release Version:** \`${tag}\`\r\n**Compare Changes:** https://github.com/Expensify/App/compare/production...staging\r\n`; - - // PR list - if (!_.isEmpty(sortedPRList)) { - issueBody += '\r\n**This release contains changes from the following pull requests:**\r\n'; - _.each(sortedPRList, (URL) => { - issueBody += _.contains(verifiedOrNoQAPRs, URL) ? '- [x]' : '- [ ]'; - issueBody += ` ${URL}\r\n`; - }); - issueBody += '\r\n\r\n'; - } + const internalQAPRs = _.filter(data, (pr) => !_.isEmpty(_.findWhere(pr.labels, {name: CONST.LABELS.INTERNAL_QA}))); + return Promise.all(_.map(internalQAPRs, (pr) => this.getPullRequestMergerLogin(pr.number).then((mergerLogin) => ({url: pr.html_url, mergerLogin})))).then((results) => { + // The format of this map is following: + // { + // 'https://github.com/Expensify/App/pull/9641': 'PauloGasparSv', + // 'https://github.com/Expensify/App/pull/9642': 'mountiny' + // } + const internalQAPRMap = _.reduce( + results, + (acc, {url, mergerLogin}) => { + acc[url] = mergerLogin; + return acc; + }, + {}, + ); + console.log('Found the following Internal QA PRs:', internalQAPRMap); + + const noQAPRs = _.pluck( + _.filter(data, (PR) => /\[No\s?QA]/i.test(PR.title)), + 'html_url', + ); + console.log('Found the following NO QA PRs:', noQAPRs); + const verifiedOrNoQAPRs = _.union(verifiedPRList, noQAPRs); + + const sortedPRList = _.chain(PRList).difference(_.keys(internalQAPRMap)).unique().sortBy(GithubUtils.getPullRequestNumberFromURL).value(); + const sortedDeployBlockers = _.sortBy(_.unique(deployBlockers), GithubUtils.getIssueOrPullRequestNumberFromURL); + + // Tag version and comparison URL + // eslint-disable-next-line max-len + let issueBody = `**Release Version:** \`${tag}\`\r\n**Compare Changes:** https://github.com/Expensify/App/compare/production...staging\r\n`; + + // PR list + if (!_.isEmpty(sortedPRList)) { + issueBody += '\r\n**This release contains changes from the following pull requests:**\r\n'; + _.each(sortedPRList, (URL) => { + issueBody += _.contains(verifiedOrNoQAPRs, URL) ? '- [x]' : '- [ ]'; + issueBody += ` ${URL}\r\n`; + }); + issueBody += '\r\n\r\n'; + } - // Internal QA PR list - if (!_.isEmpty(internalQAPRMap)) { - console.log('Found the following verified Internal QA PRs:', resolvedInternalQAPRs); - issueBody += '**Internal QA:**\r\n'; - _.each(internalQAPRMap, (assignees, URL) => { - const assigneeMentions = _.reduce(assignees, (memo, assignee) => `${memo} @${assignee}`, ''); - issueBody += `${_.contains(resolvedInternalQAPRs, URL) ? '- [x]' : '- [ ]'} `; - issueBody += `${URL}`; - issueBody += ` -${assigneeMentions}`; - issueBody += '\r\n'; - }); - issueBody += '\r\n\r\n'; - } + // Internal QA PR list + if (!_.isEmpty(internalQAPRMap)) { + console.log('Found the following verified Internal QA PRs:', resolvedInternalQAPRs); + issueBody += '**Internal QA:**\r\n'; + _.each(internalQAPRMap, (merger, URL) => { + const mergerMention = `@${merger}`; + issueBody += `${_.contains(resolvedInternalQAPRs, URL) ? '- [x]' : '- [ ]'} `; + issueBody += `${URL}`; + issueBody += ` - ${mergerMention}`; + issueBody += '\r\n'; + }); + issueBody += '\r\n\r\n'; + } - // Deploy blockers - if (!_.isEmpty(deployBlockers)) { - issueBody += '**Deploy Blockers:**\r\n'; - _.each(sortedDeployBlockers, (URL) => { - issueBody += _.contains(resolvedDeployBlockers, URL) ? '- [x] ' : '- [ ] '; - issueBody += URL; - issueBody += '\r\n'; - }); - issueBody += '\r\n\r\n'; - } + // Deploy blockers + if (!_.isEmpty(deployBlockers)) { + issueBody += '**Deploy Blockers:**\r\n'; + _.each(sortedDeployBlockers, (URL) => { + issueBody += _.contains(resolvedDeployBlockers, URL) ? '- [x] ' : '- [ ] '; + issueBody += URL; + issueBody += '\r\n'; + }); + issueBody += '\r\n\r\n'; + } - issueBody += '**Deployer verifications:**'; - // eslint-disable-next-line max-len - issueBody += `\r\n- [${ - isTimingDashboardChecked ? 'x' : ' ' - }] I checked the [App Timing Dashboard](https://graphs.expensify.com/grafana/d/yj2EobAGz/app-timing?orgId=1) and verified this release does not cause a noticeable performance regression.`; - // eslint-disable-next-line max-len - issueBody += `\r\n- [${ - isFirebaseChecked ? 'x' : ' ' - }] I checked [Firebase Crashlytics](https://console.firebase.google.com/u/0/project/expensify-chat/crashlytics/app/android:com.expensify.chat/issues?state=open&time=last-seven-days&tag=all) and verified that this release does not introduce any new crashes. More detailed instructions on this verification can be found [here](https://stackoverflowteams.com/c/expensify/questions/15095/15096).`; - // eslint-disable-next-line max-len - issueBody += `\r\n- [${isGHStatusChecked ? 'x' : ' '}] I checked [GitHub Status](https://www.githubstatus.com/) and verified there is no reported incident with Actions.`; - - issueBody += '\r\n\r\ncc @Expensify/applauseleads\r\n'; - return issueBody; + issueBody += '**Deployer verifications:**'; + // eslint-disable-next-line max-len + issueBody += `\r\n- [${ + isTimingDashboardChecked ? 'x' : ' ' + }] I checked the [App Timing Dashboard](https://graphs.expensify.com/grafana/d/yj2EobAGz/app-timing?orgId=1) and verified this release does not cause a noticeable performance regression.`; + // eslint-disable-next-line max-len + issueBody += `\r\n- [${ + isFirebaseChecked ? 'x' : ' ' + }] I checked [Firebase Crashlytics](https://console.firebase.google.com/u/0/project/expensify-chat/crashlytics/app/android:com.expensify.chat/issues?state=open&time=last-seven-days&tag=all) and verified that this release does not introduce any new crashes. More detailed instructions on this verification can be found [here](https://stackoverflowteams.com/c/expensify/questions/15095/15096).`; + // eslint-disable-next-line max-len + issueBody += `\r\n- [${isGHStatusChecked ? 'x' : ' '}] I checked [GitHub Status](https://www.githubstatus.com/) and verified there is no reported incident with Actions.`; + + issueBody += '\r\n\r\ncc @Expensify/applauseleads\r\n'; + const issueAssignees = _.uniq(_.values(internalQAPRMap)); + const issue = {issueBody, issueAssignees}; + return issue; + }); }) .catch((err) => console.warn('Error generating StagingDeployCash issue body! Continuing...', err)); } @@ -431,6 +435,20 @@ class GithubUtils { .catch((err) => console.error('Failed to get PR list', err)); } + /** + * @param {Number} pullRequestNumber + * @returns {Promise} + */ + static getPullRequestMergerLogin(pullRequestNumber) { + return this.octokit.pulls + .get({ + owner: CONST.GITHUB_OWNER, + repo: CONST.APP_REPO, + pull_number: pullRequestNumber, + }) + .then(({data: pullRequest}) => pullRequest.merged_by.login); + } + /** * @param {Number} pullRequestNumber * @returns {Promise} diff --git a/.github/actions/javascript/getDeployPullRequestList/index.js b/.github/actions/javascript/getDeployPullRequestList/index.js index f272929d536a..c57ebf0fefe4 100644 --- a/.github/actions/javascript/getDeployPullRequestList/index.js +++ b/.github/actions/javascript/getDeployPullRequestList/index.js @@ -349,7 +349,7 @@ class GithubUtils { } /** - * Generate the issue body for a StagingDeployCash. + * Generate the issue body and assignees for a StagingDeployCash. * * @param {String} tag * @param {Array} PRList - The list of PR URLs which are included in this StagingDeployCash @@ -362,7 +362,7 @@ class GithubUtils { * @param {Boolean} [isGHStatusChecked] * @returns {Promise} */ - static generateStagingDeployCashBody( + static generateStagingDeployCashBodyAndAssignees( tag, PRList, verifiedPRList = [], @@ -375,85 +375,89 @@ class GithubUtils { ) { return this.fetchAllPullRequests(_.map(PRList, this.getPullRequestNumberFromURL)) .then((data) => { - // The format of this map is following: - // { - // 'https://github.com/Expensify/App/pull/9641': [ 'PauloGasparSv', 'kidroca' ], - // 'https://github.com/Expensify/App/pull/9642': [ 'mountiny', 'kidroca' ] - // } - const internalQAPRMap = _.reduce( - _.filter(data, (pr) => !_.isEmpty(_.findWhere(pr.labels, {name: CONST.LABELS.INTERNAL_QA}))), - (map, pr) => { - // eslint-disable-next-line no-param-reassign - map[pr.html_url] = _.compact(_.pluck(pr.assignees, 'login')); - return map; - }, - {}, - ); - console.log('Found the following Internal QA PRs:', internalQAPRMap); - - const noQAPRs = _.pluck( - _.filter(data, (PR) => /\[No\s?QA]/i.test(PR.title)), - 'html_url', - ); - console.log('Found the following NO QA PRs:', noQAPRs); - const verifiedOrNoQAPRs = _.union(verifiedPRList, noQAPRs); - - const sortedPRList = _.chain(PRList).difference(_.keys(internalQAPRMap)).unique().sortBy(GithubUtils.getPullRequestNumberFromURL).value(); - const sortedDeployBlockers = _.sortBy(_.unique(deployBlockers), GithubUtils.getIssueOrPullRequestNumberFromURL); - - // Tag version and comparison URL - // eslint-disable-next-line max-len - let issueBody = `**Release Version:** \`${tag}\`\r\n**Compare Changes:** https://github.com/Expensify/App/compare/production...staging\r\n`; - - // PR list - if (!_.isEmpty(sortedPRList)) { - issueBody += '\r\n**This release contains changes from the following pull requests:**\r\n'; - _.each(sortedPRList, (URL) => { - issueBody += _.contains(verifiedOrNoQAPRs, URL) ? '- [x]' : '- [ ]'; - issueBody += ` ${URL}\r\n`; - }); - issueBody += '\r\n\r\n'; - } + const internalQAPRs = _.filter(data, (pr) => !_.isEmpty(_.findWhere(pr.labels, {name: CONST.LABELS.INTERNAL_QA}))); + return Promise.all(_.map(internalQAPRs, (pr) => this.getPullRequestMergerLogin(pr.number).then((mergerLogin) => ({url: pr.html_url, mergerLogin})))).then((results) => { + // The format of this map is following: + // { + // 'https://github.com/Expensify/App/pull/9641': 'PauloGasparSv', + // 'https://github.com/Expensify/App/pull/9642': 'mountiny' + // } + const internalQAPRMap = _.reduce( + results, + (acc, {url, mergerLogin}) => { + acc[url] = mergerLogin; + return acc; + }, + {}, + ); + console.log('Found the following Internal QA PRs:', internalQAPRMap); + + const noQAPRs = _.pluck( + _.filter(data, (PR) => /\[No\s?QA]/i.test(PR.title)), + 'html_url', + ); + console.log('Found the following NO QA PRs:', noQAPRs); + const verifiedOrNoQAPRs = _.union(verifiedPRList, noQAPRs); + + const sortedPRList = _.chain(PRList).difference(_.keys(internalQAPRMap)).unique().sortBy(GithubUtils.getPullRequestNumberFromURL).value(); + const sortedDeployBlockers = _.sortBy(_.unique(deployBlockers), GithubUtils.getIssueOrPullRequestNumberFromURL); + + // Tag version and comparison URL + // eslint-disable-next-line max-len + let issueBody = `**Release Version:** \`${tag}\`\r\n**Compare Changes:** https://github.com/Expensify/App/compare/production...staging\r\n`; + + // PR list + if (!_.isEmpty(sortedPRList)) { + issueBody += '\r\n**This release contains changes from the following pull requests:**\r\n'; + _.each(sortedPRList, (URL) => { + issueBody += _.contains(verifiedOrNoQAPRs, URL) ? '- [x]' : '- [ ]'; + issueBody += ` ${URL}\r\n`; + }); + issueBody += '\r\n\r\n'; + } - // Internal QA PR list - if (!_.isEmpty(internalQAPRMap)) { - console.log('Found the following verified Internal QA PRs:', resolvedInternalQAPRs); - issueBody += '**Internal QA:**\r\n'; - _.each(internalQAPRMap, (assignees, URL) => { - const assigneeMentions = _.reduce(assignees, (memo, assignee) => `${memo} @${assignee}`, ''); - issueBody += `${_.contains(resolvedInternalQAPRs, URL) ? '- [x]' : '- [ ]'} `; - issueBody += `${URL}`; - issueBody += ` -${assigneeMentions}`; - issueBody += '\r\n'; - }); - issueBody += '\r\n\r\n'; - } + // Internal QA PR list + if (!_.isEmpty(internalQAPRMap)) { + console.log('Found the following verified Internal QA PRs:', resolvedInternalQAPRs); + issueBody += '**Internal QA:**\r\n'; + _.each(internalQAPRMap, (merger, URL) => { + const mergerMention = `@${merger}`; + issueBody += `${_.contains(resolvedInternalQAPRs, URL) ? '- [x]' : '- [ ]'} `; + issueBody += `${URL}`; + issueBody += ` - ${mergerMention}`; + issueBody += '\r\n'; + }); + issueBody += '\r\n\r\n'; + } - // Deploy blockers - if (!_.isEmpty(deployBlockers)) { - issueBody += '**Deploy Blockers:**\r\n'; - _.each(sortedDeployBlockers, (URL) => { - issueBody += _.contains(resolvedDeployBlockers, URL) ? '- [x] ' : '- [ ] '; - issueBody += URL; - issueBody += '\r\n'; - }); - issueBody += '\r\n\r\n'; - } + // Deploy blockers + if (!_.isEmpty(deployBlockers)) { + issueBody += '**Deploy Blockers:**\r\n'; + _.each(sortedDeployBlockers, (URL) => { + issueBody += _.contains(resolvedDeployBlockers, URL) ? '- [x] ' : '- [ ] '; + issueBody += URL; + issueBody += '\r\n'; + }); + issueBody += '\r\n\r\n'; + } - issueBody += '**Deployer verifications:**'; - // eslint-disable-next-line max-len - issueBody += `\r\n- [${ - isTimingDashboardChecked ? 'x' : ' ' - }] I checked the [App Timing Dashboard](https://graphs.expensify.com/grafana/d/yj2EobAGz/app-timing?orgId=1) and verified this release does not cause a noticeable performance regression.`; - // eslint-disable-next-line max-len - issueBody += `\r\n- [${ - isFirebaseChecked ? 'x' : ' ' - }] I checked [Firebase Crashlytics](https://console.firebase.google.com/u/0/project/expensify-chat/crashlytics/app/android:com.expensify.chat/issues?state=open&time=last-seven-days&tag=all) and verified that this release does not introduce any new crashes. More detailed instructions on this verification can be found [here](https://stackoverflowteams.com/c/expensify/questions/15095/15096).`; - // eslint-disable-next-line max-len - issueBody += `\r\n- [${isGHStatusChecked ? 'x' : ' '}] I checked [GitHub Status](https://www.githubstatus.com/) and verified there is no reported incident with Actions.`; - - issueBody += '\r\n\r\ncc @Expensify/applauseleads\r\n'; - return issueBody; + issueBody += '**Deployer verifications:**'; + // eslint-disable-next-line max-len + issueBody += `\r\n- [${ + isTimingDashboardChecked ? 'x' : ' ' + }] I checked the [App Timing Dashboard](https://graphs.expensify.com/grafana/d/yj2EobAGz/app-timing?orgId=1) and verified this release does not cause a noticeable performance regression.`; + // eslint-disable-next-line max-len + issueBody += `\r\n- [${ + isFirebaseChecked ? 'x' : ' ' + }] I checked [Firebase Crashlytics](https://console.firebase.google.com/u/0/project/expensify-chat/crashlytics/app/android:com.expensify.chat/issues?state=open&time=last-seven-days&tag=all) and verified that this release does not introduce any new crashes. More detailed instructions on this verification can be found [here](https://stackoverflowteams.com/c/expensify/questions/15095/15096).`; + // eslint-disable-next-line max-len + issueBody += `\r\n- [${isGHStatusChecked ? 'x' : ' '}] I checked [GitHub Status](https://www.githubstatus.com/) and verified there is no reported incident with Actions.`; + + issueBody += '\r\n\r\ncc @Expensify/applauseleads\r\n'; + const issueAssignees = _.uniq(_.values(internalQAPRMap)); + const issue = {issueBody, issueAssignees}; + return issue; + }); }) .catch((err) => console.warn('Error generating StagingDeployCash issue body! Continuing...', err)); } @@ -487,6 +491,20 @@ class GithubUtils { .catch((err) => console.error('Failed to get PR list', err)); } + /** + * @param {Number} pullRequestNumber + * @returns {Promise} + */ + static getPullRequestMergerLogin(pullRequestNumber) { + return this.octokit.pulls + .get({ + owner: CONST.GITHUB_OWNER, + repo: CONST.APP_REPO, + pull_number: pullRequestNumber, + }) + .then(({data: pullRequest}) => pullRequest.merged_by.login); + } + /** * @param {Number} pullRequestNumber * @returns {Promise} diff --git a/.github/actions/javascript/getPullRequestDetails/index.js b/.github/actions/javascript/getPullRequestDetails/index.js index b8d7d821d64e..9eb608a8cc05 100644 --- a/.github/actions/javascript/getPullRequestDetails/index.js +++ b/.github/actions/javascript/getPullRequestDetails/index.js @@ -301,7 +301,7 @@ class GithubUtils { } /** - * Generate the issue body for a StagingDeployCash. + * Generate the issue body and assignees for a StagingDeployCash. * * @param {String} tag * @param {Array} PRList - The list of PR URLs which are included in this StagingDeployCash @@ -314,7 +314,7 @@ class GithubUtils { * @param {Boolean} [isGHStatusChecked] * @returns {Promise} */ - static generateStagingDeployCashBody( + static generateStagingDeployCashBodyAndAssignees( tag, PRList, verifiedPRList = [], @@ -327,85 +327,89 @@ class GithubUtils { ) { return this.fetchAllPullRequests(_.map(PRList, this.getPullRequestNumberFromURL)) .then((data) => { - // The format of this map is following: - // { - // 'https://github.com/Expensify/App/pull/9641': [ 'PauloGasparSv', 'kidroca' ], - // 'https://github.com/Expensify/App/pull/9642': [ 'mountiny', 'kidroca' ] - // } - const internalQAPRMap = _.reduce( - _.filter(data, (pr) => !_.isEmpty(_.findWhere(pr.labels, {name: CONST.LABELS.INTERNAL_QA}))), - (map, pr) => { - // eslint-disable-next-line no-param-reassign - map[pr.html_url] = _.compact(_.pluck(pr.assignees, 'login')); - return map; - }, - {}, - ); - console.log('Found the following Internal QA PRs:', internalQAPRMap); - - const noQAPRs = _.pluck( - _.filter(data, (PR) => /\[No\s?QA]/i.test(PR.title)), - 'html_url', - ); - console.log('Found the following NO QA PRs:', noQAPRs); - const verifiedOrNoQAPRs = _.union(verifiedPRList, noQAPRs); - - const sortedPRList = _.chain(PRList).difference(_.keys(internalQAPRMap)).unique().sortBy(GithubUtils.getPullRequestNumberFromURL).value(); - const sortedDeployBlockers = _.sortBy(_.unique(deployBlockers), GithubUtils.getIssueOrPullRequestNumberFromURL); - - // Tag version and comparison URL - // eslint-disable-next-line max-len - let issueBody = `**Release Version:** \`${tag}\`\r\n**Compare Changes:** https://github.com/Expensify/App/compare/production...staging\r\n`; - - // PR list - if (!_.isEmpty(sortedPRList)) { - issueBody += '\r\n**This release contains changes from the following pull requests:**\r\n'; - _.each(sortedPRList, (URL) => { - issueBody += _.contains(verifiedOrNoQAPRs, URL) ? '- [x]' : '- [ ]'; - issueBody += ` ${URL}\r\n`; - }); - issueBody += '\r\n\r\n'; - } + const internalQAPRs = _.filter(data, (pr) => !_.isEmpty(_.findWhere(pr.labels, {name: CONST.LABELS.INTERNAL_QA}))); + return Promise.all(_.map(internalQAPRs, (pr) => this.getPullRequestMergerLogin(pr.number).then((mergerLogin) => ({url: pr.html_url, mergerLogin})))).then((results) => { + // The format of this map is following: + // { + // 'https://github.com/Expensify/App/pull/9641': 'PauloGasparSv', + // 'https://github.com/Expensify/App/pull/9642': 'mountiny' + // } + const internalQAPRMap = _.reduce( + results, + (acc, {url, mergerLogin}) => { + acc[url] = mergerLogin; + return acc; + }, + {}, + ); + console.log('Found the following Internal QA PRs:', internalQAPRMap); + + const noQAPRs = _.pluck( + _.filter(data, (PR) => /\[No\s?QA]/i.test(PR.title)), + 'html_url', + ); + console.log('Found the following NO QA PRs:', noQAPRs); + const verifiedOrNoQAPRs = _.union(verifiedPRList, noQAPRs); + + const sortedPRList = _.chain(PRList).difference(_.keys(internalQAPRMap)).unique().sortBy(GithubUtils.getPullRequestNumberFromURL).value(); + const sortedDeployBlockers = _.sortBy(_.unique(deployBlockers), GithubUtils.getIssueOrPullRequestNumberFromURL); + + // Tag version and comparison URL + // eslint-disable-next-line max-len + let issueBody = `**Release Version:** \`${tag}\`\r\n**Compare Changes:** https://github.com/Expensify/App/compare/production...staging\r\n`; + + // PR list + if (!_.isEmpty(sortedPRList)) { + issueBody += '\r\n**This release contains changes from the following pull requests:**\r\n'; + _.each(sortedPRList, (URL) => { + issueBody += _.contains(verifiedOrNoQAPRs, URL) ? '- [x]' : '- [ ]'; + issueBody += ` ${URL}\r\n`; + }); + issueBody += '\r\n\r\n'; + } - // Internal QA PR list - if (!_.isEmpty(internalQAPRMap)) { - console.log('Found the following verified Internal QA PRs:', resolvedInternalQAPRs); - issueBody += '**Internal QA:**\r\n'; - _.each(internalQAPRMap, (assignees, URL) => { - const assigneeMentions = _.reduce(assignees, (memo, assignee) => `${memo} @${assignee}`, ''); - issueBody += `${_.contains(resolvedInternalQAPRs, URL) ? '- [x]' : '- [ ]'} `; - issueBody += `${URL}`; - issueBody += ` -${assigneeMentions}`; - issueBody += '\r\n'; - }); - issueBody += '\r\n\r\n'; - } + // Internal QA PR list + if (!_.isEmpty(internalQAPRMap)) { + console.log('Found the following verified Internal QA PRs:', resolvedInternalQAPRs); + issueBody += '**Internal QA:**\r\n'; + _.each(internalQAPRMap, (merger, URL) => { + const mergerMention = `@${merger}`; + issueBody += `${_.contains(resolvedInternalQAPRs, URL) ? '- [x]' : '- [ ]'} `; + issueBody += `${URL}`; + issueBody += ` - ${mergerMention}`; + issueBody += '\r\n'; + }); + issueBody += '\r\n\r\n'; + } - // Deploy blockers - if (!_.isEmpty(deployBlockers)) { - issueBody += '**Deploy Blockers:**\r\n'; - _.each(sortedDeployBlockers, (URL) => { - issueBody += _.contains(resolvedDeployBlockers, URL) ? '- [x] ' : '- [ ] '; - issueBody += URL; - issueBody += '\r\n'; - }); - issueBody += '\r\n\r\n'; - } + // Deploy blockers + if (!_.isEmpty(deployBlockers)) { + issueBody += '**Deploy Blockers:**\r\n'; + _.each(sortedDeployBlockers, (URL) => { + issueBody += _.contains(resolvedDeployBlockers, URL) ? '- [x] ' : '- [ ] '; + issueBody += URL; + issueBody += '\r\n'; + }); + issueBody += '\r\n\r\n'; + } - issueBody += '**Deployer verifications:**'; - // eslint-disable-next-line max-len - issueBody += `\r\n- [${ - isTimingDashboardChecked ? 'x' : ' ' - }] I checked the [App Timing Dashboard](https://graphs.expensify.com/grafana/d/yj2EobAGz/app-timing?orgId=1) and verified this release does not cause a noticeable performance regression.`; - // eslint-disable-next-line max-len - issueBody += `\r\n- [${ - isFirebaseChecked ? 'x' : ' ' - }] I checked [Firebase Crashlytics](https://console.firebase.google.com/u/0/project/expensify-chat/crashlytics/app/android:com.expensify.chat/issues?state=open&time=last-seven-days&tag=all) and verified that this release does not introduce any new crashes. More detailed instructions on this verification can be found [here](https://stackoverflowteams.com/c/expensify/questions/15095/15096).`; - // eslint-disable-next-line max-len - issueBody += `\r\n- [${isGHStatusChecked ? 'x' : ' '}] I checked [GitHub Status](https://www.githubstatus.com/) and verified there is no reported incident with Actions.`; - - issueBody += '\r\n\r\ncc @Expensify/applauseleads\r\n'; - return issueBody; + issueBody += '**Deployer verifications:**'; + // eslint-disable-next-line max-len + issueBody += `\r\n- [${ + isTimingDashboardChecked ? 'x' : ' ' + }] I checked the [App Timing Dashboard](https://graphs.expensify.com/grafana/d/yj2EobAGz/app-timing?orgId=1) and verified this release does not cause a noticeable performance regression.`; + // eslint-disable-next-line max-len + issueBody += `\r\n- [${ + isFirebaseChecked ? 'x' : ' ' + }] I checked [Firebase Crashlytics](https://console.firebase.google.com/u/0/project/expensify-chat/crashlytics/app/android:com.expensify.chat/issues?state=open&time=last-seven-days&tag=all) and verified that this release does not introduce any new crashes. More detailed instructions on this verification can be found [here](https://stackoverflowteams.com/c/expensify/questions/15095/15096).`; + // eslint-disable-next-line max-len + issueBody += `\r\n- [${isGHStatusChecked ? 'x' : ' '}] I checked [GitHub Status](https://www.githubstatus.com/) and verified there is no reported incident with Actions.`; + + issueBody += '\r\n\r\ncc @Expensify/applauseleads\r\n'; + const issueAssignees = _.uniq(_.values(internalQAPRMap)); + const issue = {issueBody, issueAssignees}; + return issue; + }); }) .catch((err) => console.warn('Error generating StagingDeployCash issue body! Continuing...', err)); } @@ -439,6 +443,20 @@ class GithubUtils { .catch((err) => console.error('Failed to get PR list', err)); } + /** + * @param {Number} pullRequestNumber + * @returns {Promise} + */ + static getPullRequestMergerLogin(pullRequestNumber) { + return this.octokit.pulls + .get({ + owner: CONST.GITHUB_OWNER, + repo: CONST.APP_REPO, + pull_number: pullRequestNumber, + }) + .then(({data: pullRequest}) => pullRequest.merged_by.login); + } + /** * @param {Number} pullRequestNumber * @returns {Promise} diff --git a/.github/actions/javascript/getReleaseBody/index.js b/.github/actions/javascript/getReleaseBody/index.js index cc1321ce5cd5..ea12ef5f0df0 100644 --- a/.github/actions/javascript/getReleaseBody/index.js +++ b/.github/actions/javascript/getReleaseBody/index.js @@ -301,7 +301,7 @@ class GithubUtils { } /** - * Generate the issue body for a StagingDeployCash. + * Generate the issue body and assignees for a StagingDeployCash. * * @param {String} tag * @param {Array} PRList - The list of PR URLs which are included in this StagingDeployCash @@ -314,7 +314,7 @@ class GithubUtils { * @param {Boolean} [isGHStatusChecked] * @returns {Promise} */ - static generateStagingDeployCashBody( + static generateStagingDeployCashBodyAndAssignees( tag, PRList, verifiedPRList = [], @@ -327,85 +327,89 @@ class GithubUtils { ) { return this.fetchAllPullRequests(_.map(PRList, this.getPullRequestNumberFromURL)) .then((data) => { - // The format of this map is following: - // { - // 'https://github.com/Expensify/App/pull/9641': [ 'PauloGasparSv', 'kidroca' ], - // 'https://github.com/Expensify/App/pull/9642': [ 'mountiny', 'kidroca' ] - // } - const internalQAPRMap = _.reduce( - _.filter(data, (pr) => !_.isEmpty(_.findWhere(pr.labels, {name: CONST.LABELS.INTERNAL_QA}))), - (map, pr) => { - // eslint-disable-next-line no-param-reassign - map[pr.html_url] = _.compact(_.pluck(pr.assignees, 'login')); - return map; - }, - {}, - ); - console.log('Found the following Internal QA PRs:', internalQAPRMap); - - const noQAPRs = _.pluck( - _.filter(data, (PR) => /\[No\s?QA]/i.test(PR.title)), - 'html_url', - ); - console.log('Found the following NO QA PRs:', noQAPRs); - const verifiedOrNoQAPRs = _.union(verifiedPRList, noQAPRs); - - const sortedPRList = _.chain(PRList).difference(_.keys(internalQAPRMap)).unique().sortBy(GithubUtils.getPullRequestNumberFromURL).value(); - const sortedDeployBlockers = _.sortBy(_.unique(deployBlockers), GithubUtils.getIssueOrPullRequestNumberFromURL); - - // Tag version and comparison URL - // eslint-disable-next-line max-len - let issueBody = `**Release Version:** \`${tag}\`\r\n**Compare Changes:** https://github.com/Expensify/App/compare/production...staging\r\n`; - - // PR list - if (!_.isEmpty(sortedPRList)) { - issueBody += '\r\n**This release contains changes from the following pull requests:**\r\n'; - _.each(sortedPRList, (URL) => { - issueBody += _.contains(verifiedOrNoQAPRs, URL) ? '- [x]' : '- [ ]'; - issueBody += ` ${URL}\r\n`; - }); - issueBody += '\r\n\r\n'; - } + const internalQAPRs = _.filter(data, (pr) => !_.isEmpty(_.findWhere(pr.labels, {name: CONST.LABELS.INTERNAL_QA}))); + return Promise.all(_.map(internalQAPRs, (pr) => this.getPullRequestMergerLogin(pr.number).then((mergerLogin) => ({url: pr.html_url, mergerLogin})))).then((results) => { + // The format of this map is following: + // { + // 'https://github.com/Expensify/App/pull/9641': 'PauloGasparSv', + // 'https://github.com/Expensify/App/pull/9642': 'mountiny' + // } + const internalQAPRMap = _.reduce( + results, + (acc, {url, mergerLogin}) => { + acc[url] = mergerLogin; + return acc; + }, + {}, + ); + console.log('Found the following Internal QA PRs:', internalQAPRMap); + + const noQAPRs = _.pluck( + _.filter(data, (PR) => /\[No\s?QA]/i.test(PR.title)), + 'html_url', + ); + console.log('Found the following NO QA PRs:', noQAPRs); + const verifiedOrNoQAPRs = _.union(verifiedPRList, noQAPRs); + + const sortedPRList = _.chain(PRList).difference(_.keys(internalQAPRMap)).unique().sortBy(GithubUtils.getPullRequestNumberFromURL).value(); + const sortedDeployBlockers = _.sortBy(_.unique(deployBlockers), GithubUtils.getIssueOrPullRequestNumberFromURL); + + // Tag version and comparison URL + // eslint-disable-next-line max-len + let issueBody = `**Release Version:** \`${tag}\`\r\n**Compare Changes:** https://github.com/Expensify/App/compare/production...staging\r\n`; + + // PR list + if (!_.isEmpty(sortedPRList)) { + issueBody += '\r\n**This release contains changes from the following pull requests:**\r\n'; + _.each(sortedPRList, (URL) => { + issueBody += _.contains(verifiedOrNoQAPRs, URL) ? '- [x]' : '- [ ]'; + issueBody += ` ${URL}\r\n`; + }); + issueBody += '\r\n\r\n'; + } - // Internal QA PR list - if (!_.isEmpty(internalQAPRMap)) { - console.log('Found the following verified Internal QA PRs:', resolvedInternalQAPRs); - issueBody += '**Internal QA:**\r\n'; - _.each(internalQAPRMap, (assignees, URL) => { - const assigneeMentions = _.reduce(assignees, (memo, assignee) => `${memo} @${assignee}`, ''); - issueBody += `${_.contains(resolvedInternalQAPRs, URL) ? '- [x]' : '- [ ]'} `; - issueBody += `${URL}`; - issueBody += ` -${assigneeMentions}`; - issueBody += '\r\n'; - }); - issueBody += '\r\n\r\n'; - } + // Internal QA PR list + if (!_.isEmpty(internalQAPRMap)) { + console.log('Found the following verified Internal QA PRs:', resolvedInternalQAPRs); + issueBody += '**Internal QA:**\r\n'; + _.each(internalQAPRMap, (merger, URL) => { + const mergerMention = `@${merger}`; + issueBody += `${_.contains(resolvedInternalQAPRs, URL) ? '- [x]' : '- [ ]'} `; + issueBody += `${URL}`; + issueBody += ` - ${mergerMention}`; + issueBody += '\r\n'; + }); + issueBody += '\r\n\r\n'; + } - // Deploy blockers - if (!_.isEmpty(deployBlockers)) { - issueBody += '**Deploy Blockers:**\r\n'; - _.each(sortedDeployBlockers, (URL) => { - issueBody += _.contains(resolvedDeployBlockers, URL) ? '- [x] ' : '- [ ] '; - issueBody += URL; - issueBody += '\r\n'; - }); - issueBody += '\r\n\r\n'; - } + // Deploy blockers + if (!_.isEmpty(deployBlockers)) { + issueBody += '**Deploy Blockers:**\r\n'; + _.each(sortedDeployBlockers, (URL) => { + issueBody += _.contains(resolvedDeployBlockers, URL) ? '- [x] ' : '- [ ] '; + issueBody += URL; + issueBody += '\r\n'; + }); + issueBody += '\r\n\r\n'; + } - issueBody += '**Deployer verifications:**'; - // eslint-disable-next-line max-len - issueBody += `\r\n- [${ - isTimingDashboardChecked ? 'x' : ' ' - }] I checked the [App Timing Dashboard](https://graphs.expensify.com/grafana/d/yj2EobAGz/app-timing?orgId=1) and verified this release does not cause a noticeable performance regression.`; - // eslint-disable-next-line max-len - issueBody += `\r\n- [${ - isFirebaseChecked ? 'x' : ' ' - }] I checked [Firebase Crashlytics](https://console.firebase.google.com/u/0/project/expensify-chat/crashlytics/app/android:com.expensify.chat/issues?state=open&time=last-seven-days&tag=all) and verified that this release does not introduce any new crashes. More detailed instructions on this verification can be found [here](https://stackoverflowteams.com/c/expensify/questions/15095/15096).`; - // eslint-disable-next-line max-len - issueBody += `\r\n- [${isGHStatusChecked ? 'x' : ' '}] I checked [GitHub Status](https://www.githubstatus.com/) and verified there is no reported incident with Actions.`; - - issueBody += '\r\n\r\ncc @Expensify/applauseleads\r\n'; - return issueBody; + issueBody += '**Deployer verifications:**'; + // eslint-disable-next-line max-len + issueBody += `\r\n- [${ + isTimingDashboardChecked ? 'x' : ' ' + }] I checked the [App Timing Dashboard](https://graphs.expensify.com/grafana/d/yj2EobAGz/app-timing?orgId=1) and verified this release does not cause a noticeable performance regression.`; + // eslint-disable-next-line max-len + issueBody += `\r\n- [${ + isFirebaseChecked ? 'x' : ' ' + }] I checked [Firebase Crashlytics](https://console.firebase.google.com/u/0/project/expensify-chat/crashlytics/app/android:com.expensify.chat/issues?state=open&time=last-seven-days&tag=all) and verified that this release does not introduce any new crashes. More detailed instructions on this verification can be found [here](https://stackoverflowteams.com/c/expensify/questions/15095/15096).`; + // eslint-disable-next-line max-len + issueBody += `\r\n- [${isGHStatusChecked ? 'x' : ' '}] I checked [GitHub Status](https://www.githubstatus.com/) and verified there is no reported incident with Actions.`; + + issueBody += '\r\n\r\ncc @Expensify/applauseleads\r\n'; + const issueAssignees = _.uniq(_.values(internalQAPRMap)); + const issue = {issueBody, issueAssignees}; + return issue; + }); }) .catch((err) => console.warn('Error generating StagingDeployCash issue body! Continuing...', err)); } @@ -439,6 +443,20 @@ class GithubUtils { .catch((err) => console.error('Failed to get PR list', err)); } + /** + * @param {Number} pullRequestNumber + * @returns {Promise} + */ + static getPullRequestMergerLogin(pullRequestNumber) { + return this.octokit.pulls + .get({ + owner: CONST.GITHUB_OWNER, + repo: CONST.APP_REPO, + pull_number: pullRequestNumber, + }) + .then(({data: pullRequest}) => pullRequest.merged_by.login); + } + /** * @param {Number} pullRequestNumber * @returns {Promise} diff --git a/.github/actions/javascript/isStagingDeployLocked/index.js b/.github/actions/javascript/isStagingDeployLocked/index.js index 8124c5795a5a..32b8b64d7b82 100644 --- a/.github/actions/javascript/isStagingDeployLocked/index.js +++ b/.github/actions/javascript/isStagingDeployLocked/index.js @@ -285,7 +285,7 @@ class GithubUtils { } /** - * Generate the issue body for a StagingDeployCash. + * Generate the issue body and assignees for a StagingDeployCash. * * @param {String} tag * @param {Array} PRList - The list of PR URLs which are included in this StagingDeployCash @@ -298,7 +298,7 @@ class GithubUtils { * @param {Boolean} [isGHStatusChecked] * @returns {Promise} */ - static generateStagingDeployCashBody( + static generateStagingDeployCashBodyAndAssignees( tag, PRList, verifiedPRList = [], @@ -311,85 +311,89 @@ class GithubUtils { ) { return this.fetchAllPullRequests(_.map(PRList, this.getPullRequestNumberFromURL)) .then((data) => { - // The format of this map is following: - // { - // 'https://github.com/Expensify/App/pull/9641': [ 'PauloGasparSv', 'kidroca' ], - // 'https://github.com/Expensify/App/pull/9642': [ 'mountiny', 'kidroca' ] - // } - const internalQAPRMap = _.reduce( - _.filter(data, (pr) => !_.isEmpty(_.findWhere(pr.labels, {name: CONST.LABELS.INTERNAL_QA}))), - (map, pr) => { - // eslint-disable-next-line no-param-reassign - map[pr.html_url] = _.compact(_.pluck(pr.assignees, 'login')); - return map; - }, - {}, - ); - console.log('Found the following Internal QA PRs:', internalQAPRMap); - - const noQAPRs = _.pluck( - _.filter(data, (PR) => /\[No\s?QA]/i.test(PR.title)), - 'html_url', - ); - console.log('Found the following NO QA PRs:', noQAPRs); - const verifiedOrNoQAPRs = _.union(verifiedPRList, noQAPRs); - - const sortedPRList = _.chain(PRList).difference(_.keys(internalQAPRMap)).unique().sortBy(GithubUtils.getPullRequestNumberFromURL).value(); - const sortedDeployBlockers = _.sortBy(_.unique(deployBlockers), GithubUtils.getIssueOrPullRequestNumberFromURL); - - // Tag version and comparison URL - // eslint-disable-next-line max-len - let issueBody = `**Release Version:** \`${tag}\`\r\n**Compare Changes:** https://github.com/Expensify/App/compare/production...staging\r\n`; - - // PR list - if (!_.isEmpty(sortedPRList)) { - issueBody += '\r\n**This release contains changes from the following pull requests:**\r\n'; - _.each(sortedPRList, (URL) => { - issueBody += _.contains(verifiedOrNoQAPRs, URL) ? '- [x]' : '- [ ]'; - issueBody += ` ${URL}\r\n`; - }); - issueBody += '\r\n\r\n'; - } + const internalQAPRs = _.filter(data, (pr) => !_.isEmpty(_.findWhere(pr.labels, {name: CONST.LABELS.INTERNAL_QA}))); + return Promise.all(_.map(internalQAPRs, (pr) => this.getPullRequestMergerLogin(pr.number).then((mergerLogin) => ({url: pr.html_url, mergerLogin})))).then((results) => { + // The format of this map is following: + // { + // 'https://github.com/Expensify/App/pull/9641': 'PauloGasparSv', + // 'https://github.com/Expensify/App/pull/9642': 'mountiny' + // } + const internalQAPRMap = _.reduce( + results, + (acc, {url, mergerLogin}) => { + acc[url] = mergerLogin; + return acc; + }, + {}, + ); + console.log('Found the following Internal QA PRs:', internalQAPRMap); + + const noQAPRs = _.pluck( + _.filter(data, (PR) => /\[No\s?QA]/i.test(PR.title)), + 'html_url', + ); + console.log('Found the following NO QA PRs:', noQAPRs); + const verifiedOrNoQAPRs = _.union(verifiedPRList, noQAPRs); + + const sortedPRList = _.chain(PRList).difference(_.keys(internalQAPRMap)).unique().sortBy(GithubUtils.getPullRequestNumberFromURL).value(); + const sortedDeployBlockers = _.sortBy(_.unique(deployBlockers), GithubUtils.getIssueOrPullRequestNumberFromURL); + + // Tag version and comparison URL + // eslint-disable-next-line max-len + let issueBody = `**Release Version:** \`${tag}\`\r\n**Compare Changes:** https://github.com/Expensify/App/compare/production...staging\r\n`; + + // PR list + if (!_.isEmpty(sortedPRList)) { + issueBody += '\r\n**This release contains changes from the following pull requests:**\r\n'; + _.each(sortedPRList, (URL) => { + issueBody += _.contains(verifiedOrNoQAPRs, URL) ? '- [x]' : '- [ ]'; + issueBody += ` ${URL}\r\n`; + }); + issueBody += '\r\n\r\n'; + } - // Internal QA PR list - if (!_.isEmpty(internalQAPRMap)) { - console.log('Found the following verified Internal QA PRs:', resolvedInternalQAPRs); - issueBody += '**Internal QA:**\r\n'; - _.each(internalQAPRMap, (assignees, URL) => { - const assigneeMentions = _.reduce(assignees, (memo, assignee) => `${memo} @${assignee}`, ''); - issueBody += `${_.contains(resolvedInternalQAPRs, URL) ? '- [x]' : '- [ ]'} `; - issueBody += `${URL}`; - issueBody += ` -${assigneeMentions}`; - issueBody += '\r\n'; - }); - issueBody += '\r\n\r\n'; - } + // Internal QA PR list + if (!_.isEmpty(internalQAPRMap)) { + console.log('Found the following verified Internal QA PRs:', resolvedInternalQAPRs); + issueBody += '**Internal QA:**\r\n'; + _.each(internalQAPRMap, (merger, URL) => { + const mergerMention = `@${merger}`; + issueBody += `${_.contains(resolvedInternalQAPRs, URL) ? '- [x]' : '- [ ]'} `; + issueBody += `${URL}`; + issueBody += ` - ${mergerMention}`; + issueBody += '\r\n'; + }); + issueBody += '\r\n\r\n'; + } - // Deploy blockers - if (!_.isEmpty(deployBlockers)) { - issueBody += '**Deploy Blockers:**\r\n'; - _.each(sortedDeployBlockers, (URL) => { - issueBody += _.contains(resolvedDeployBlockers, URL) ? '- [x] ' : '- [ ] '; - issueBody += URL; - issueBody += '\r\n'; - }); - issueBody += '\r\n\r\n'; - } + // Deploy blockers + if (!_.isEmpty(deployBlockers)) { + issueBody += '**Deploy Blockers:**\r\n'; + _.each(sortedDeployBlockers, (URL) => { + issueBody += _.contains(resolvedDeployBlockers, URL) ? '- [x] ' : '- [ ] '; + issueBody += URL; + issueBody += '\r\n'; + }); + issueBody += '\r\n\r\n'; + } - issueBody += '**Deployer verifications:**'; - // eslint-disable-next-line max-len - issueBody += `\r\n- [${ - isTimingDashboardChecked ? 'x' : ' ' - }] I checked the [App Timing Dashboard](https://graphs.expensify.com/grafana/d/yj2EobAGz/app-timing?orgId=1) and verified this release does not cause a noticeable performance regression.`; - // eslint-disable-next-line max-len - issueBody += `\r\n- [${ - isFirebaseChecked ? 'x' : ' ' - }] I checked [Firebase Crashlytics](https://console.firebase.google.com/u/0/project/expensify-chat/crashlytics/app/android:com.expensify.chat/issues?state=open&time=last-seven-days&tag=all) and verified that this release does not introduce any new crashes. More detailed instructions on this verification can be found [here](https://stackoverflowteams.com/c/expensify/questions/15095/15096).`; - // eslint-disable-next-line max-len - issueBody += `\r\n- [${isGHStatusChecked ? 'x' : ' '}] I checked [GitHub Status](https://www.githubstatus.com/) and verified there is no reported incident with Actions.`; - - issueBody += '\r\n\r\ncc @Expensify/applauseleads\r\n'; - return issueBody; + issueBody += '**Deployer verifications:**'; + // eslint-disable-next-line max-len + issueBody += `\r\n- [${ + isTimingDashboardChecked ? 'x' : ' ' + }] I checked the [App Timing Dashboard](https://graphs.expensify.com/grafana/d/yj2EobAGz/app-timing?orgId=1) and verified this release does not cause a noticeable performance regression.`; + // eslint-disable-next-line max-len + issueBody += `\r\n- [${ + isFirebaseChecked ? 'x' : ' ' + }] I checked [Firebase Crashlytics](https://console.firebase.google.com/u/0/project/expensify-chat/crashlytics/app/android:com.expensify.chat/issues?state=open&time=last-seven-days&tag=all) and verified that this release does not introduce any new crashes. More detailed instructions on this verification can be found [here](https://stackoverflowteams.com/c/expensify/questions/15095/15096).`; + // eslint-disable-next-line max-len + issueBody += `\r\n- [${isGHStatusChecked ? 'x' : ' '}] I checked [GitHub Status](https://www.githubstatus.com/) and verified there is no reported incident with Actions.`; + + issueBody += '\r\n\r\ncc @Expensify/applauseleads\r\n'; + const issueAssignees = _.uniq(_.values(internalQAPRMap)); + const issue = {issueBody, issueAssignees}; + return issue; + }); }) .catch((err) => console.warn('Error generating StagingDeployCash issue body! Continuing...', err)); } @@ -423,6 +427,20 @@ class GithubUtils { .catch((err) => console.error('Failed to get PR list', err)); } + /** + * @param {Number} pullRequestNumber + * @returns {Promise} + */ + static getPullRequestMergerLogin(pullRequestNumber) { + return this.octokit.pulls + .get({ + owner: CONST.GITHUB_OWNER, + repo: CONST.APP_REPO, + pull_number: pullRequestNumber, + }) + .then(({data: pullRequest}) => pullRequest.merged_by.login); + } + /** * @param {Number} pullRequestNumber * @returns {Promise} diff --git a/.github/actions/javascript/markPullRequestsAsDeployed/index.js b/.github/actions/javascript/markPullRequestsAsDeployed/index.js index 36cd0aaefe4a..d275edf9d789 100644 --- a/.github/actions/javascript/markPullRequestsAsDeployed/index.js +++ b/.github/actions/javascript/markPullRequestsAsDeployed/index.js @@ -450,7 +450,7 @@ class GithubUtils { } /** - * Generate the issue body for a StagingDeployCash. + * Generate the issue body and assignees for a StagingDeployCash. * * @param {String} tag * @param {Array} PRList - The list of PR URLs which are included in this StagingDeployCash @@ -463,7 +463,7 @@ class GithubUtils { * @param {Boolean} [isGHStatusChecked] * @returns {Promise} */ - static generateStagingDeployCashBody( + static generateStagingDeployCashBodyAndAssignees( tag, PRList, verifiedPRList = [], @@ -476,85 +476,89 @@ class GithubUtils { ) { return this.fetchAllPullRequests(_.map(PRList, this.getPullRequestNumberFromURL)) .then((data) => { - // The format of this map is following: - // { - // 'https://github.com/Expensify/App/pull/9641': [ 'PauloGasparSv', 'kidroca' ], - // 'https://github.com/Expensify/App/pull/9642': [ 'mountiny', 'kidroca' ] - // } - const internalQAPRMap = _.reduce( - _.filter(data, (pr) => !_.isEmpty(_.findWhere(pr.labels, {name: CONST.LABELS.INTERNAL_QA}))), - (map, pr) => { - // eslint-disable-next-line no-param-reassign - map[pr.html_url] = _.compact(_.pluck(pr.assignees, 'login')); - return map; - }, - {}, - ); - console.log('Found the following Internal QA PRs:', internalQAPRMap); - - const noQAPRs = _.pluck( - _.filter(data, (PR) => /\[No\s?QA]/i.test(PR.title)), - 'html_url', - ); - console.log('Found the following NO QA PRs:', noQAPRs); - const verifiedOrNoQAPRs = _.union(verifiedPRList, noQAPRs); - - const sortedPRList = _.chain(PRList).difference(_.keys(internalQAPRMap)).unique().sortBy(GithubUtils.getPullRequestNumberFromURL).value(); - const sortedDeployBlockers = _.sortBy(_.unique(deployBlockers), GithubUtils.getIssueOrPullRequestNumberFromURL); - - // Tag version and comparison URL - // eslint-disable-next-line max-len - let issueBody = `**Release Version:** \`${tag}\`\r\n**Compare Changes:** https://github.com/Expensify/App/compare/production...staging\r\n`; - - // PR list - if (!_.isEmpty(sortedPRList)) { - issueBody += '\r\n**This release contains changes from the following pull requests:**\r\n'; - _.each(sortedPRList, (URL) => { - issueBody += _.contains(verifiedOrNoQAPRs, URL) ? '- [x]' : '- [ ]'; - issueBody += ` ${URL}\r\n`; - }); - issueBody += '\r\n\r\n'; - } + const internalQAPRs = _.filter(data, (pr) => !_.isEmpty(_.findWhere(pr.labels, {name: CONST.LABELS.INTERNAL_QA}))); + return Promise.all(_.map(internalQAPRs, (pr) => this.getPullRequestMergerLogin(pr.number).then((mergerLogin) => ({url: pr.html_url, mergerLogin})))).then((results) => { + // The format of this map is following: + // { + // 'https://github.com/Expensify/App/pull/9641': 'PauloGasparSv', + // 'https://github.com/Expensify/App/pull/9642': 'mountiny' + // } + const internalQAPRMap = _.reduce( + results, + (acc, {url, mergerLogin}) => { + acc[url] = mergerLogin; + return acc; + }, + {}, + ); + console.log('Found the following Internal QA PRs:', internalQAPRMap); + + const noQAPRs = _.pluck( + _.filter(data, (PR) => /\[No\s?QA]/i.test(PR.title)), + 'html_url', + ); + console.log('Found the following NO QA PRs:', noQAPRs); + const verifiedOrNoQAPRs = _.union(verifiedPRList, noQAPRs); + + const sortedPRList = _.chain(PRList).difference(_.keys(internalQAPRMap)).unique().sortBy(GithubUtils.getPullRequestNumberFromURL).value(); + const sortedDeployBlockers = _.sortBy(_.unique(deployBlockers), GithubUtils.getIssueOrPullRequestNumberFromURL); + + // Tag version and comparison URL + // eslint-disable-next-line max-len + let issueBody = `**Release Version:** \`${tag}\`\r\n**Compare Changes:** https://github.com/Expensify/App/compare/production...staging\r\n`; + + // PR list + if (!_.isEmpty(sortedPRList)) { + issueBody += '\r\n**This release contains changes from the following pull requests:**\r\n'; + _.each(sortedPRList, (URL) => { + issueBody += _.contains(verifiedOrNoQAPRs, URL) ? '- [x]' : '- [ ]'; + issueBody += ` ${URL}\r\n`; + }); + issueBody += '\r\n\r\n'; + } - // Internal QA PR list - if (!_.isEmpty(internalQAPRMap)) { - console.log('Found the following verified Internal QA PRs:', resolvedInternalQAPRs); - issueBody += '**Internal QA:**\r\n'; - _.each(internalQAPRMap, (assignees, URL) => { - const assigneeMentions = _.reduce(assignees, (memo, assignee) => `${memo} @${assignee}`, ''); - issueBody += `${_.contains(resolvedInternalQAPRs, URL) ? '- [x]' : '- [ ]'} `; - issueBody += `${URL}`; - issueBody += ` -${assigneeMentions}`; - issueBody += '\r\n'; - }); - issueBody += '\r\n\r\n'; - } + // Internal QA PR list + if (!_.isEmpty(internalQAPRMap)) { + console.log('Found the following verified Internal QA PRs:', resolvedInternalQAPRs); + issueBody += '**Internal QA:**\r\n'; + _.each(internalQAPRMap, (merger, URL) => { + const mergerMention = `@${merger}`; + issueBody += `${_.contains(resolvedInternalQAPRs, URL) ? '- [x]' : '- [ ]'} `; + issueBody += `${URL}`; + issueBody += ` - ${mergerMention}`; + issueBody += '\r\n'; + }); + issueBody += '\r\n\r\n'; + } - // Deploy blockers - if (!_.isEmpty(deployBlockers)) { - issueBody += '**Deploy Blockers:**\r\n'; - _.each(sortedDeployBlockers, (URL) => { - issueBody += _.contains(resolvedDeployBlockers, URL) ? '- [x] ' : '- [ ] '; - issueBody += URL; - issueBody += '\r\n'; - }); - issueBody += '\r\n\r\n'; - } + // Deploy blockers + if (!_.isEmpty(deployBlockers)) { + issueBody += '**Deploy Blockers:**\r\n'; + _.each(sortedDeployBlockers, (URL) => { + issueBody += _.contains(resolvedDeployBlockers, URL) ? '- [x] ' : '- [ ] '; + issueBody += URL; + issueBody += '\r\n'; + }); + issueBody += '\r\n\r\n'; + } - issueBody += '**Deployer verifications:**'; - // eslint-disable-next-line max-len - issueBody += `\r\n- [${ - isTimingDashboardChecked ? 'x' : ' ' - }] I checked the [App Timing Dashboard](https://graphs.expensify.com/grafana/d/yj2EobAGz/app-timing?orgId=1) and verified this release does not cause a noticeable performance regression.`; - // eslint-disable-next-line max-len - issueBody += `\r\n- [${ - isFirebaseChecked ? 'x' : ' ' - }] I checked [Firebase Crashlytics](https://console.firebase.google.com/u/0/project/expensify-chat/crashlytics/app/android:com.expensify.chat/issues?state=open&time=last-seven-days&tag=all) and verified that this release does not introduce any new crashes. More detailed instructions on this verification can be found [here](https://stackoverflowteams.com/c/expensify/questions/15095/15096).`; - // eslint-disable-next-line max-len - issueBody += `\r\n- [${isGHStatusChecked ? 'x' : ' '}] I checked [GitHub Status](https://www.githubstatus.com/) and verified there is no reported incident with Actions.`; - - issueBody += '\r\n\r\ncc @Expensify/applauseleads\r\n'; - return issueBody; + issueBody += '**Deployer verifications:**'; + // eslint-disable-next-line max-len + issueBody += `\r\n- [${ + isTimingDashboardChecked ? 'x' : ' ' + }] I checked the [App Timing Dashboard](https://graphs.expensify.com/grafana/d/yj2EobAGz/app-timing?orgId=1) and verified this release does not cause a noticeable performance regression.`; + // eslint-disable-next-line max-len + issueBody += `\r\n- [${ + isFirebaseChecked ? 'x' : ' ' + }] I checked [Firebase Crashlytics](https://console.firebase.google.com/u/0/project/expensify-chat/crashlytics/app/android:com.expensify.chat/issues?state=open&time=last-seven-days&tag=all) and verified that this release does not introduce any new crashes. More detailed instructions on this verification can be found [here](https://stackoverflowteams.com/c/expensify/questions/15095/15096).`; + // eslint-disable-next-line max-len + issueBody += `\r\n- [${isGHStatusChecked ? 'x' : ' '}] I checked [GitHub Status](https://www.githubstatus.com/) and verified there is no reported incident with Actions.`; + + issueBody += '\r\n\r\ncc @Expensify/applauseleads\r\n'; + const issueAssignees = _.uniq(_.values(internalQAPRMap)); + const issue = {issueBody, issueAssignees}; + return issue; + }); }) .catch((err) => console.warn('Error generating StagingDeployCash issue body! Continuing...', err)); } @@ -588,6 +592,20 @@ class GithubUtils { .catch((err) => console.error('Failed to get PR list', err)); } + /** + * @param {Number} pullRequestNumber + * @returns {Promise} + */ + static getPullRequestMergerLogin(pullRequestNumber) { + return this.octokit.pulls + .get({ + owner: CONST.GITHUB_OWNER, + repo: CONST.APP_REPO, + pull_number: pullRequestNumber, + }) + .then(({data: pullRequest}) => pullRequest.merged_by.login); + } + /** * @param {Number} pullRequestNumber * @returns {Promise} diff --git a/.github/actions/javascript/postTestBuildComment/index.js b/.github/actions/javascript/postTestBuildComment/index.js index 329e0d3aad5d..3bd3e6121be8 100644 --- a/.github/actions/javascript/postTestBuildComment/index.js +++ b/.github/actions/javascript/postTestBuildComment/index.js @@ -360,7 +360,7 @@ class GithubUtils { } /** - * Generate the issue body for a StagingDeployCash. + * Generate the issue body and assignees for a StagingDeployCash. * * @param {String} tag * @param {Array} PRList - The list of PR URLs which are included in this StagingDeployCash @@ -373,7 +373,7 @@ class GithubUtils { * @param {Boolean} [isGHStatusChecked] * @returns {Promise} */ - static generateStagingDeployCashBody( + static generateStagingDeployCashBodyAndAssignees( tag, PRList, verifiedPRList = [], @@ -386,85 +386,89 @@ class GithubUtils { ) { return this.fetchAllPullRequests(_.map(PRList, this.getPullRequestNumberFromURL)) .then((data) => { - // The format of this map is following: - // { - // 'https://github.com/Expensify/App/pull/9641': [ 'PauloGasparSv', 'kidroca' ], - // 'https://github.com/Expensify/App/pull/9642': [ 'mountiny', 'kidroca' ] - // } - const internalQAPRMap = _.reduce( - _.filter(data, (pr) => !_.isEmpty(_.findWhere(pr.labels, {name: CONST.LABELS.INTERNAL_QA}))), - (map, pr) => { - // eslint-disable-next-line no-param-reassign - map[pr.html_url] = _.compact(_.pluck(pr.assignees, 'login')); - return map; - }, - {}, - ); - console.log('Found the following Internal QA PRs:', internalQAPRMap); - - const noQAPRs = _.pluck( - _.filter(data, (PR) => /\[No\s?QA]/i.test(PR.title)), - 'html_url', - ); - console.log('Found the following NO QA PRs:', noQAPRs); - const verifiedOrNoQAPRs = _.union(verifiedPRList, noQAPRs); - - const sortedPRList = _.chain(PRList).difference(_.keys(internalQAPRMap)).unique().sortBy(GithubUtils.getPullRequestNumberFromURL).value(); - const sortedDeployBlockers = _.sortBy(_.unique(deployBlockers), GithubUtils.getIssueOrPullRequestNumberFromURL); - - // Tag version and comparison URL - // eslint-disable-next-line max-len - let issueBody = `**Release Version:** \`${tag}\`\r\n**Compare Changes:** https://github.com/Expensify/App/compare/production...staging\r\n`; - - // PR list - if (!_.isEmpty(sortedPRList)) { - issueBody += '\r\n**This release contains changes from the following pull requests:**\r\n'; - _.each(sortedPRList, (URL) => { - issueBody += _.contains(verifiedOrNoQAPRs, URL) ? '- [x]' : '- [ ]'; - issueBody += ` ${URL}\r\n`; - }); - issueBody += '\r\n\r\n'; - } + const internalQAPRs = _.filter(data, (pr) => !_.isEmpty(_.findWhere(pr.labels, {name: CONST.LABELS.INTERNAL_QA}))); + return Promise.all(_.map(internalQAPRs, (pr) => this.getPullRequestMergerLogin(pr.number).then((mergerLogin) => ({url: pr.html_url, mergerLogin})))).then((results) => { + // The format of this map is following: + // { + // 'https://github.com/Expensify/App/pull/9641': 'PauloGasparSv', + // 'https://github.com/Expensify/App/pull/9642': 'mountiny' + // } + const internalQAPRMap = _.reduce( + results, + (acc, {url, mergerLogin}) => { + acc[url] = mergerLogin; + return acc; + }, + {}, + ); + console.log('Found the following Internal QA PRs:', internalQAPRMap); + + const noQAPRs = _.pluck( + _.filter(data, (PR) => /\[No\s?QA]/i.test(PR.title)), + 'html_url', + ); + console.log('Found the following NO QA PRs:', noQAPRs); + const verifiedOrNoQAPRs = _.union(verifiedPRList, noQAPRs); + + const sortedPRList = _.chain(PRList).difference(_.keys(internalQAPRMap)).unique().sortBy(GithubUtils.getPullRequestNumberFromURL).value(); + const sortedDeployBlockers = _.sortBy(_.unique(deployBlockers), GithubUtils.getIssueOrPullRequestNumberFromURL); + + // Tag version and comparison URL + // eslint-disable-next-line max-len + let issueBody = `**Release Version:** \`${tag}\`\r\n**Compare Changes:** https://github.com/Expensify/App/compare/production...staging\r\n`; + + // PR list + if (!_.isEmpty(sortedPRList)) { + issueBody += '\r\n**This release contains changes from the following pull requests:**\r\n'; + _.each(sortedPRList, (URL) => { + issueBody += _.contains(verifiedOrNoQAPRs, URL) ? '- [x]' : '- [ ]'; + issueBody += ` ${URL}\r\n`; + }); + issueBody += '\r\n\r\n'; + } - // Internal QA PR list - if (!_.isEmpty(internalQAPRMap)) { - console.log('Found the following verified Internal QA PRs:', resolvedInternalQAPRs); - issueBody += '**Internal QA:**\r\n'; - _.each(internalQAPRMap, (assignees, URL) => { - const assigneeMentions = _.reduce(assignees, (memo, assignee) => `${memo} @${assignee}`, ''); - issueBody += `${_.contains(resolvedInternalQAPRs, URL) ? '- [x]' : '- [ ]'} `; - issueBody += `${URL}`; - issueBody += ` -${assigneeMentions}`; - issueBody += '\r\n'; - }); - issueBody += '\r\n\r\n'; - } + // Internal QA PR list + if (!_.isEmpty(internalQAPRMap)) { + console.log('Found the following verified Internal QA PRs:', resolvedInternalQAPRs); + issueBody += '**Internal QA:**\r\n'; + _.each(internalQAPRMap, (merger, URL) => { + const mergerMention = `@${merger}`; + issueBody += `${_.contains(resolvedInternalQAPRs, URL) ? '- [x]' : '- [ ]'} `; + issueBody += `${URL}`; + issueBody += ` - ${mergerMention}`; + issueBody += '\r\n'; + }); + issueBody += '\r\n\r\n'; + } - // Deploy blockers - if (!_.isEmpty(deployBlockers)) { - issueBody += '**Deploy Blockers:**\r\n'; - _.each(sortedDeployBlockers, (URL) => { - issueBody += _.contains(resolvedDeployBlockers, URL) ? '- [x] ' : '- [ ] '; - issueBody += URL; - issueBody += '\r\n'; - }); - issueBody += '\r\n\r\n'; - } + // Deploy blockers + if (!_.isEmpty(deployBlockers)) { + issueBody += '**Deploy Blockers:**\r\n'; + _.each(sortedDeployBlockers, (URL) => { + issueBody += _.contains(resolvedDeployBlockers, URL) ? '- [x] ' : '- [ ] '; + issueBody += URL; + issueBody += '\r\n'; + }); + issueBody += '\r\n\r\n'; + } - issueBody += '**Deployer verifications:**'; - // eslint-disable-next-line max-len - issueBody += `\r\n- [${ - isTimingDashboardChecked ? 'x' : ' ' - }] I checked the [App Timing Dashboard](https://graphs.expensify.com/grafana/d/yj2EobAGz/app-timing?orgId=1) and verified this release does not cause a noticeable performance regression.`; - // eslint-disable-next-line max-len - issueBody += `\r\n- [${ - isFirebaseChecked ? 'x' : ' ' - }] I checked [Firebase Crashlytics](https://console.firebase.google.com/u/0/project/expensify-chat/crashlytics/app/android:com.expensify.chat/issues?state=open&time=last-seven-days&tag=all) and verified that this release does not introduce any new crashes. More detailed instructions on this verification can be found [here](https://stackoverflowteams.com/c/expensify/questions/15095/15096).`; - // eslint-disable-next-line max-len - issueBody += `\r\n- [${isGHStatusChecked ? 'x' : ' '}] I checked [GitHub Status](https://www.githubstatus.com/) and verified there is no reported incident with Actions.`; - - issueBody += '\r\n\r\ncc @Expensify/applauseleads\r\n'; - return issueBody; + issueBody += '**Deployer verifications:**'; + // eslint-disable-next-line max-len + issueBody += `\r\n- [${ + isTimingDashboardChecked ? 'x' : ' ' + }] I checked the [App Timing Dashboard](https://graphs.expensify.com/grafana/d/yj2EobAGz/app-timing?orgId=1) and verified this release does not cause a noticeable performance regression.`; + // eslint-disable-next-line max-len + issueBody += `\r\n- [${ + isFirebaseChecked ? 'x' : ' ' + }] I checked [Firebase Crashlytics](https://console.firebase.google.com/u/0/project/expensify-chat/crashlytics/app/android:com.expensify.chat/issues?state=open&time=last-seven-days&tag=all) and verified that this release does not introduce any new crashes. More detailed instructions on this verification can be found [here](https://stackoverflowteams.com/c/expensify/questions/15095/15096).`; + // eslint-disable-next-line max-len + issueBody += `\r\n- [${isGHStatusChecked ? 'x' : ' '}] I checked [GitHub Status](https://www.githubstatus.com/) and verified there is no reported incident with Actions.`; + + issueBody += '\r\n\r\ncc @Expensify/applauseleads\r\n'; + const issueAssignees = _.uniq(_.values(internalQAPRMap)); + const issue = {issueBody, issueAssignees}; + return issue; + }); }) .catch((err) => console.warn('Error generating StagingDeployCash issue body! Continuing...', err)); } @@ -498,6 +502,20 @@ class GithubUtils { .catch((err) => console.error('Failed to get PR list', err)); } + /** + * @param {Number} pullRequestNumber + * @returns {Promise} + */ + static getPullRequestMergerLogin(pullRequestNumber) { + return this.octokit.pulls + .get({ + owner: CONST.GITHUB_OWNER, + repo: CONST.APP_REPO, + pull_number: pullRequestNumber, + }) + .then(({data: pullRequest}) => pullRequest.merged_by.login); + } + /** * @param {Number} pullRequestNumber * @returns {Promise} diff --git a/.github/actions/javascript/reopenIssueWithComment/index.js b/.github/actions/javascript/reopenIssueWithComment/index.js index 6a5f89badb5e..9c740914dc1b 100644 --- a/.github/actions/javascript/reopenIssueWithComment/index.js +++ b/.github/actions/javascript/reopenIssueWithComment/index.js @@ -255,7 +255,7 @@ class GithubUtils { } /** - * Generate the issue body for a StagingDeployCash. + * Generate the issue body and assignees for a StagingDeployCash. * * @param {String} tag * @param {Array} PRList - The list of PR URLs which are included in this StagingDeployCash @@ -268,7 +268,7 @@ class GithubUtils { * @param {Boolean} [isGHStatusChecked] * @returns {Promise} */ - static generateStagingDeployCashBody( + static generateStagingDeployCashBodyAndAssignees( tag, PRList, verifiedPRList = [], @@ -281,85 +281,89 @@ class GithubUtils { ) { return this.fetchAllPullRequests(_.map(PRList, this.getPullRequestNumberFromURL)) .then((data) => { - // The format of this map is following: - // { - // 'https://github.com/Expensify/App/pull/9641': [ 'PauloGasparSv', 'kidroca' ], - // 'https://github.com/Expensify/App/pull/9642': [ 'mountiny', 'kidroca' ] - // } - const internalQAPRMap = _.reduce( - _.filter(data, (pr) => !_.isEmpty(_.findWhere(pr.labels, {name: CONST.LABELS.INTERNAL_QA}))), - (map, pr) => { - // eslint-disable-next-line no-param-reassign - map[pr.html_url] = _.compact(_.pluck(pr.assignees, 'login')); - return map; - }, - {}, - ); - console.log('Found the following Internal QA PRs:', internalQAPRMap); - - const noQAPRs = _.pluck( - _.filter(data, (PR) => /\[No\s?QA]/i.test(PR.title)), - 'html_url', - ); - console.log('Found the following NO QA PRs:', noQAPRs); - const verifiedOrNoQAPRs = _.union(verifiedPRList, noQAPRs); - - const sortedPRList = _.chain(PRList).difference(_.keys(internalQAPRMap)).unique().sortBy(GithubUtils.getPullRequestNumberFromURL).value(); - const sortedDeployBlockers = _.sortBy(_.unique(deployBlockers), GithubUtils.getIssueOrPullRequestNumberFromURL); - - // Tag version and comparison URL - // eslint-disable-next-line max-len - let issueBody = `**Release Version:** \`${tag}\`\r\n**Compare Changes:** https://github.com/Expensify/App/compare/production...staging\r\n`; - - // PR list - if (!_.isEmpty(sortedPRList)) { - issueBody += '\r\n**This release contains changes from the following pull requests:**\r\n'; - _.each(sortedPRList, (URL) => { - issueBody += _.contains(verifiedOrNoQAPRs, URL) ? '- [x]' : '- [ ]'; - issueBody += ` ${URL}\r\n`; - }); - issueBody += '\r\n\r\n'; - } + const internalQAPRs = _.filter(data, (pr) => !_.isEmpty(_.findWhere(pr.labels, {name: CONST.LABELS.INTERNAL_QA}))); + return Promise.all(_.map(internalQAPRs, (pr) => this.getPullRequestMergerLogin(pr.number).then((mergerLogin) => ({url: pr.html_url, mergerLogin})))).then((results) => { + // The format of this map is following: + // { + // 'https://github.com/Expensify/App/pull/9641': 'PauloGasparSv', + // 'https://github.com/Expensify/App/pull/9642': 'mountiny' + // } + const internalQAPRMap = _.reduce( + results, + (acc, {url, mergerLogin}) => { + acc[url] = mergerLogin; + return acc; + }, + {}, + ); + console.log('Found the following Internal QA PRs:', internalQAPRMap); + + const noQAPRs = _.pluck( + _.filter(data, (PR) => /\[No\s?QA]/i.test(PR.title)), + 'html_url', + ); + console.log('Found the following NO QA PRs:', noQAPRs); + const verifiedOrNoQAPRs = _.union(verifiedPRList, noQAPRs); + + const sortedPRList = _.chain(PRList).difference(_.keys(internalQAPRMap)).unique().sortBy(GithubUtils.getPullRequestNumberFromURL).value(); + const sortedDeployBlockers = _.sortBy(_.unique(deployBlockers), GithubUtils.getIssueOrPullRequestNumberFromURL); + + // Tag version and comparison URL + // eslint-disable-next-line max-len + let issueBody = `**Release Version:** \`${tag}\`\r\n**Compare Changes:** https://github.com/Expensify/App/compare/production...staging\r\n`; + + // PR list + if (!_.isEmpty(sortedPRList)) { + issueBody += '\r\n**This release contains changes from the following pull requests:**\r\n'; + _.each(sortedPRList, (URL) => { + issueBody += _.contains(verifiedOrNoQAPRs, URL) ? '- [x]' : '- [ ]'; + issueBody += ` ${URL}\r\n`; + }); + issueBody += '\r\n\r\n'; + } - // Internal QA PR list - if (!_.isEmpty(internalQAPRMap)) { - console.log('Found the following verified Internal QA PRs:', resolvedInternalQAPRs); - issueBody += '**Internal QA:**\r\n'; - _.each(internalQAPRMap, (assignees, URL) => { - const assigneeMentions = _.reduce(assignees, (memo, assignee) => `${memo} @${assignee}`, ''); - issueBody += `${_.contains(resolvedInternalQAPRs, URL) ? '- [x]' : '- [ ]'} `; - issueBody += `${URL}`; - issueBody += ` -${assigneeMentions}`; - issueBody += '\r\n'; - }); - issueBody += '\r\n\r\n'; - } + // Internal QA PR list + if (!_.isEmpty(internalQAPRMap)) { + console.log('Found the following verified Internal QA PRs:', resolvedInternalQAPRs); + issueBody += '**Internal QA:**\r\n'; + _.each(internalQAPRMap, (merger, URL) => { + const mergerMention = `@${merger}`; + issueBody += `${_.contains(resolvedInternalQAPRs, URL) ? '- [x]' : '- [ ]'} `; + issueBody += `${URL}`; + issueBody += ` - ${mergerMention}`; + issueBody += '\r\n'; + }); + issueBody += '\r\n\r\n'; + } - // Deploy blockers - if (!_.isEmpty(deployBlockers)) { - issueBody += '**Deploy Blockers:**\r\n'; - _.each(sortedDeployBlockers, (URL) => { - issueBody += _.contains(resolvedDeployBlockers, URL) ? '- [x] ' : '- [ ] '; - issueBody += URL; - issueBody += '\r\n'; - }); - issueBody += '\r\n\r\n'; - } + // Deploy blockers + if (!_.isEmpty(deployBlockers)) { + issueBody += '**Deploy Blockers:**\r\n'; + _.each(sortedDeployBlockers, (URL) => { + issueBody += _.contains(resolvedDeployBlockers, URL) ? '- [x] ' : '- [ ] '; + issueBody += URL; + issueBody += '\r\n'; + }); + issueBody += '\r\n\r\n'; + } - issueBody += '**Deployer verifications:**'; - // eslint-disable-next-line max-len - issueBody += `\r\n- [${ - isTimingDashboardChecked ? 'x' : ' ' - }] I checked the [App Timing Dashboard](https://graphs.expensify.com/grafana/d/yj2EobAGz/app-timing?orgId=1) and verified this release does not cause a noticeable performance regression.`; - // eslint-disable-next-line max-len - issueBody += `\r\n- [${ - isFirebaseChecked ? 'x' : ' ' - }] I checked [Firebase Crashlytics](https://console.firebase.google.com/u/0/project/expensify-chat/crashlytics/app/android:com.expensify.chat/issues?state=open&time=last-seven-days&tag=all) and verified that this release does not introduce any new crashes. More detailed instructions on this verification can be found [here](https://stackoverflowteams.com/c/expensify/questions/15095/15096).`; - // eslint-disable-next-line max-len - issueBody += `\r\n- [${isGHStatusChecked ? 'x' : ' '}] I checked [GitHub Status](https://www.githubstatus.com/) and verified there is no reported incident with Actions.`; - - issueBody += '\r\n\r\ncc @Expensify/applauseleads\r\n'; - return issueBody; + issueBody += '**Deployer verifications:**'; + // eslint-disable-next-line max-len + issueBody += `\r\n- [${ + isTimingDashboardChecked ? 'x' : ' ' + }] I checked the [App Timing Dashboard](https://graphs.expensify.com/grafana/d/yj2EobAGz/app-timing?orgId=1) and verified this release does not cause a noticeable performance regression.`; + // eslint-disable-next-line max-len + issueBody += `\r\n- [${ + isFirebaseChecked ? 'x' : ' ' + }] I checked [Firebase Crashlytics](https://console.firebase.google.com/u/0/project/expensify-chat/crashlytics/app/android:com.expensify.chat/issues?state=open&time=last-seven-days&tag=all) and verified that this release does not introduce any new crashes. More detailed instructions on this verification can be found [here](https://stackoverflowteams.com/c/expensify/questions/15095/15096).`; + // eslint-disable-next-line max-len + issueBody += `\r\n- [${isGHStatusChecked ? 'x' : ' '}] I checked [GitHub Status](https://www.githubstatus.com/) and verified there is no reported incident with Actions.`; + + issueBody += '\r\n\r\ncc @Expensify/applauseleads\r\n'; + const issueAssignees = _.uniq(_.values(internalQAPRMap)); + const issue = {issueBody, issueAssignees}; + return issue; + }); }) .catch((err) => console.warn('Error generating StagingDeployCash issue body! Continuing...', err)); } @@ -393,6 +397,20 @@ class GithubUtils { .catch((err) => console.error('Failed to get PR list', err)); } + /** + * @param {Number} pullRequestNumber + * @returns {Promise} + */ + static getPullRequestMergerLogin(pullRequestNumber) { + return this.octokit.pulls + .get({ + owner: CONST.GITHUB_OWNER, + repo: CONST.APP_REPO, + pull_number: pullRequestNumber, + }) + .then(({data: pullRequest}) => pullRequest.merged_by.login); + } + /** * @param {Number} pullRequestNumber * @returns {Promise} diff --git a/.github/actions/javascript/reviewerChecklist/index.js b/.github/actions/javascript/reviewerChecklist/index.js index 322b529b89bf..7b162f06840d 100644 --- a/.github/actions/javascript/reviewerChecklist/index.js +++ b/.github/actions/javascript/reviewerChecklist/index.js @@ -255,7 +255,7 @@ class GithubUtils { } /** - * Generate the issue body for a StagingDeployCash. + * Generate the issue body and assignees for a StagingDeployCash. * * @param {String} tag * @param {Array} PRList - The list of PR URLs which are included in this StagingDeployCash @@ -268,7 +268,7 @@ class GithubUtils { * @param {Boolean} [isGHStatusChecked] * @returns {Promise} */ - static generateStagingDeployCashBody( + static generateStagingDeployCashBodyAndAssignees( tag, PRList, verifiedPRList = [], @@ -281,85 +281,89 @@ class GithubUtils { ) { return this.fetchAllPullRequests(_.map(PRList, this.getPullRequestNumberFromURL)) .then((data) => { - // The format of this map is following: - // { - // 'https://github.com/Expensify/App/pull/9641': [ 'PauloGasparSv', 'kidroca' ], - // 'https://github.com/Expensify/App/pull/9642': [ 'mountiny', 'kidroca' ] - // } - const internalQAPRMap = _.reduce( - _.filter(data, (pr) => !_.isEmpty(_.findWhere(pr.labels, {name: CONST.LABELS.INTERNAL_QA}))), - (map, pr) => { - // eslint-disable-next-line no-param-reassign - map[pr.html_url] = _.compact(_.pluck(pr.assignees, 'login')); - return map; - }, - {}, - ); - console.log('Found the following Internal QA PRs:', internalQAPRMap); - - const noQAPRs = _.pluck( - _.filter(data, (PR) => /\[No\s?QA]/i.test(PR.title)), - 'html_url', - ); - console.log('Found the following NO QA PRs:', noQAPRs); - const verifiedOrNoQAPRs = _.union(verifiedPRList, noQAPRs); - - const sortedPRList = _.chain(PRList).difference(_.keys(internalQAPRMap)).unique().sortBy(GithubUtils.getPullRequestNumberFromURL).value(); - const sortedDeployBlockers = _.sortBy(_.unique(deployBlockers), GithubUtils.getIssueOrPullRequestNumberFromURL); - - // Tag version and comparison URL - // eslint-disable-next-line max-len - let issueBody = `**Release Version:** \`${tag}\`\r\n**Compare Changes:** https://github.com/Expensify/App/compare/production...staging\r\n`; - - // PR list - if (!_.isEmpty(sortedPRList)) { - issueBody += '\r\n**This release contains changes from the following pull requests:**\r\n'; - _.each(sortedPRList, (URL) => { - issueBody += _.contains(verifiedOrNoQAPRs, URL) ? '- [x]' : '- [ ]'; - issueBody += ` ${URL}\r\n`; - }); - issueBody += '\r\n\r\n'; - } + const internalQAPRs = _.filter(data, (pr) => !_.isEmpty(_.findWhere(pr.labels, {name: CONST.LABELS.INTERNAL_QA}))); + return Promise.all(_.map(internalQAPRs, (pr) => this.getPullRequestMergerLogin(pr.number).then((mergerLogin) => ({url: pr.html_url, mergerLogin})))).then((results) => { + // The format of this map is following: + // { + // 'https://github.com/Expensify/App/pull/9641': 'PauloGasparSv', + // 'https://github.com/Expensify/App/pull/9642': 'mountiny' + // } + const internalQAPRMap = _.reduce( + results, + (acc, {url, mergerLogin}) => { + acc[url] = mergerLogin; + return acc; + }, + {}, + ); + console.log('Found the following Internal QA PRs:', internalQAPRMap); + + const noQAPRs = _.pluck( + _.filter(data, (PR) => /\[No\s?QA]/i.test(PR.title)), + 'html_url', + ); + console.log('Found the following NO QA PRs:', noQAPRs); + const verifiedOrNoQAPRs = _.union(verifiedPRList, noQAPRs); + + const sortedPRList = _.chain(PRList).difference(_.keys(internalQAPRMap)).unique().sortBy(GithubUtils.getPullRequestNumberFromURL).value(); + const sortedDeployBlockers = _.sortBy(_.unique(deployBlockers), GithubUtils.getIssueOrPullRequestNumberFromURL); + + // Tag version and comparison URL + // eslint-disable-next-line max-len + let issueBody = `**Release Version:** \`${tag}\`\r\n**Compare Changes:** https://github.com/Expensify/App/compare/production...staging\r\n`; + + // PR list + if (!_.isEmpty(sortedPRList)) { + issueBody += '\r\n**This release contains changes from the following pull requests:**\r\n'; + _.each(sortedPRList, (URL) => { + issueBody += _.contains(verifiedOrNoQAPRs, URL) ? '- [x]' : '- [ ]'; + issueBody += ` ${URL}\r\n`; + }); + issueBody += '\r\n\r\n'; + } - // Internal QA PR list - if (!_.isEmpty(internalQAPRMap)) { - console.log('Found the following verified Internal QA PRs:', resolvedInternalQAPRs); - issueBody += '**Internal QA:**\r\n'; - _.each(internalQAPRMap, (assignees, URL) => { - const assigneeMentions = _.reduce(assignees, (memo, assignee) => `${memo} @${assignee}`, ''); - issueBody += `${_.contains(resolvedInternalQAPRs, URL) ? '- [x]' : '- [ ]'} `; - issueBody += `${URL}`; - issueBody += ` -${assigneeMentions}`; - issueBody += '\r\n'; - }); - issueBody += '\r\n\r\n'; - } + // Internal QA PR list + if (!_.isEmpty(internalQAPRMap)) { + console.log('Found the following verified Internal QA PRs:', resolvedInternalQAPRs); + issueBody += '**Internal QA:**\r\n'; + _.each(internalQAPRMap, (merger, URL) => { + const mergerMention = `@${merger}`; + issueBody += `${_.contains(resolvedInternalQAPRs, URL) ? '- [x]' : '- [ ]'} `; + issueBody += `${URL}`; + issueBody += ` - ${mergerMention}`; + issueBody += '\r\n'; + }); + issueBody += '\r\n\r\n'; + } - // Deploy blockers - if (!_.isEmpty(deployBlockers)) { - issueBody += '**Deploy Blockers:**\r\n'; - _.each(sortedDeployBlockers, (URL) => { - issueBody += _.contains(resolvedDeployBlockers, URL) ? '- [x] ' : '- [ ] '; - issueBody += URL; - issueBody += '\r\n'; - }); - issueBody += '\r\n\r\n'; - } + // Deploy blockers + if (!_.isEmpty(deployBlockers)) { + issueBody += '**Deploy Blockers:**\r\n'; + _.each(sortedDeployBlockers, (URL) => { + issueBody += _.contains(resolvedDeployBlockers, URL) ? '- [x] ' : '- [ ] '; + issueBody += URL; + issueBody += '\r\n'; + }); + issueBody += '\r\n\r\n'; + } - issueBody += '**Deployer verifications:**'; - // eslint-disable-next-line max-len - issueBody += `\r\n- [${ - isTimingDashboardChecked ? 'x' : ' ' - }] I checked the [App Timing Dashboard](https://graphs.expensify.com/grafana/d/yj2EobAGz/app-timing?orgId=1) and verified this release does not cause a noticeable performance regression.`; - // eslint-disable-next-line max-len - issueBody += `\r\n- [${ - isFirebaseChecked ? 'x' : ' ' - }] I checked [Firebase Crashlytics](https://console.firebase.google.com/u/0/project/expensify-chat/crashlytics/app/android:com.expensify.chat/issues?state=open&time=last-seven-days&tag=all) and verified that this release does not introduce any new crashes. More detailed instructions on this verification can be found [here](https://stackoverflowteams.com/c/expensify/questions/15095/15096).`; - // eslint-disable-next-line max-len - issueBody += `\r\n- [${isGHStatusChecked ? 'x' : ' '}] I checked [GitHub Status](https://www.githubstatus.com/) and verified there is no reported incident with Actions.`; - - issueBody += '\r\n\r\ncc @Expensify/applauseleads\r\n'; - return issueBody; + issueBody += '**Deployer verifications:**'; + // eslint-disable-next-line max-len + issueBody += `\r\n- [${ + isTimingDashboardChecked ? 'x' : ' ' + }] I checked the [App Timing Dashboard](https://graphs.expensify.com/grafana/d/yj2EobAGz/app-timing?orgId=1) and verified this release does not cause a noticeable performance regression.`; + // eslint-disable-next-line max-len + issueBody += `\r\n- [${ + isFirebaseChecked ? 'x' : ' ' + }] I checked [Firebase Crashlytics](https://console.firebase.google.com/u/0/project/expensify-chat/crashlytics/app/android:com.expensify.chat/issues?state=open&time=last-seven-days&tag=all) and verified that this release does not introduce any new crashes. More detailed instructions on this verification can be found [here](https://stackoverflowteams.com/c/expensify/questions/15095/15096).`; + // eslint-disable-next-line max-len + issueBody += `\r\n- [${isGHStatusChecked ? 'x' : ' '}] I checked [GitHub Status](https://www.githubstatus.com/) and verified there is no reported incident with Actions.`; + + issueBody += '\r\n\r\ncc @Expensify/applauseleads\r\n'; + const issueAssignees = _.uniq(_.values(internalQAPRMap)); + const issue = {issueBody, issueAssignees}; + return issue; + }); }) .catch((err) => console.warn('Error generating StagingDeployCash issue body! Continuing...', err)); } @@ -393,6 +397,20 @@ class GithubUtils { .catch((err) => console.error('Failed to get PR list', err)); } + /** + * @param {Number} pullRequestNumber + * @returns {Promise} + */ + static getPullRequestMergerLogin(pullRequestNumber) { + return this.octokit.pulls + .get({ + owner: CONST.GITHUB_OWNER, + repo: CONST.APP_REPO, + pull_number: pullRequestNumber, + }) + .then(({data: pullRequest}) => pullRequest.merged_by.login); + } + /** * @param {Number} pullRequestNumber * @returns {Promise} diff --git a/.github/actions/javascript/verifySignedCommits/index.js b/.github/actions/javascript/verifySignedCommits/index.js index ba188d3a2b86..07173cb19bc5 100644 --- a/.github/actions/javascript/verifySignedCommits/index.js +++ b/.github/actions/javascript/verifySignedCommits/index.js @@ -255,7 +255,7 @@ class GithubUtils { } /** - * Generate the issue body for a StagingDeployCash. + * Generate the issue body and assignees for a StagingDeployCash. * * @param {String} tag * @param {Array} PRList - The list of PR URLs which are included in this StagingDeployCash @@ -268,7 +268,7 @@ class GithubUtils { * @param {Boolean} [isGHStatusChecked] * @returns {Promise} */ - static generateStagingDeployCashBody( + static generateStagingDeployCashBodyAndAssignees( tag, PRList, verifiedPRList = [], @@ -281,85 +281,89 @@ class GithubUtils { ) { return this.fetchAllPullRequests(_.map(PRList, this.getPullRequestNumberFromURL)) .then((data) => { - // The format of this map is following: - // { - // 'https://github.com/Expensify/App/pull/9641': [ 'PauloGasparSv', 'kidroca' ], - // 'https://github.com/Expensify/App/pull/9642': [ 'mountiny', 'kidroca' ] - // } - const internalQAPRMap = _.reduce( - _.filter(data, (pr) => !_.isEmpty(_.findWhere(pr.labels, {name: CONST.LABELS.INTERNAL_QA}))), - (map, pr) => { - // eslint-disable-next-line no-param-reassign - map[pr.html_url] = _.compact(_.pluck(pr.assignees, 'login')); - return map; - }, - {}, - ); - console.log('Found the following Internal QA PRs:', internalQAPRMap); - - const noQAPRs = _.pluck( - _.filter(data, (PR) => /\[No\s?QA]/i.test(PR.title)), - 'html_url', - ); - console.log('Found the following NO QA PRs:', noQAPRs); - const verifiedOrNoQAPRs = _.union(verifiedPRList, noQAPRs); - - const sortedPRList = _.chain(PRList).difference(_.keys(internalQAPRMap)).unique().sortBy(GithubUtils.getPullRequestNumberFromURL).value(); - const sortedDeployBlockers = _.sortBy(_.unique(deployBlockers), GithubUtils.getIssueOrPullRequestNumberFromURL); - - // Tag version and comparison URL - // eslint-disable-next-line max-len - let issueBody = `**Release Version:** \`${tag}\`\r\n**Compare Changes:** https://github.com/Expensify/App/compare/production...staging\r\n`; - - // PR list - if (!_.isEmpty(sortedPRList)) { - issueBody += '\r\n**This release contains changes from the following pull requests:**\r\n'; - _.each(sortedPRList, (URL) => { - issueBody += _.contains(verifiedOrNoQAPRs, URL) ? '- [x]' : '- [ ]'; - issueBody += ` ${URL}\r\n`; - }); - issueBody += '\r\n\r\n'; - } + const internalQAPRs = _.filter(data, (pr) => !_.isEmpty(_.findWhere(pr.labels, {name: CONST.LABELS.INTERNAL_QA}))); + return Promise.all(_.map(internalQAPRs, (pr) => this.getPullRequestMergerLogin(pr.number).then((mergerLogin) => ({url: pr.html_url, mergerLogin})))).then((results) => { + // The format of this map is following: + // { + // 'https://github.com/Expensify/App/pull/9641': 'PauloGasparSv', + // 'https://github.com/Expensify/App/pull/9642': 'mountiny' + // } + const internalQAPRMap = _.reduce( + results, + (acc, {url, mergerLogin}) => { + acc[url] = mergerLogin; + return acc; + }, + {}, + ); + console.log('Found the following Internal QA PRs:', internalQAPRMap); + + const noQAPRs = _.pluck( + _.filter(data, (PR) => /\[No\s?QA]/i.test(PR.title)), + 'html_url', + ); + console.log('Found the following NO QA PRs:', noQAPRs); + const verifiedOrNoQAPRs = _.union(verifiedPRList, noQAPRs); + + const sortedPRList = _.chain(PRList).difference(_.keys(internalQAPRMap)).unique().sortBy(GithubUtils.getPullRequestNumberFromURL).value(); + const sortedDeployBlockers = _.sortBy(_.unique(deployBlockers), GithubUtils.getIssueOrPullRequestNumberFromURL); + + // Tag version and comparison URL + // eslint-disable-next-line max-len + let issueBody = `**Release Version:** \`${tag}\`\r\n**Compare Changes:** https://github.com/Expensify/App/compare/production...staging\r\n`; + + // PR list + if (!_.isEmpty(sortedPRList)) { + issueBody += '\r\n**This release contains changes from the following pull requests:**\r\n'; + _.each(sortedPRList, (URL) => { + issueBody += _.contains(verifiedOrNoQAPRs, URL) ? '- [x]' : '- [ ]'; + issueBody += ` ${URL}\r\n`; + }); + issueBody += '\r\n\r\n'; + } - // Internal QA PR list - if (!_.isEmpty(internalQAPRMap)) { - console.log('Found the following verified Internal QA PRs:', resolvedInternalQAPRs); - issueBody += '**Internal QA:**\r\n'; - _.each(internalQAPRMap, (assignees, URL) => { - const assigneeMentions = _.reduce(assignees, (memo, assignee) => `${memo} @${assignee}`, ''); - issueBody += `${_.contains(resolvedInternalQAPRs, URL) ? '- [x]' : '- [ ]'} `; - issueBody += `${URL}`; - issueBody += ` -${assigneeMentions}`; - issueBody += '\r\n'; - }); - issueBody += '\r\n\r\n'; - } + // Internal QA PR list + if (!_.isEmpty(internalQAPRMap)) { + console.log('Found the following verified Internal QA PRs:', resolvedInternalQAPRs); + issueBody += '**Internal QA:**\r\n'; + _.each(internalQAPRMap, (merger, URL) => { + const mergerMention = `@${merger}`; + issueBody += `${_.contains(resolvedInternalQAPRs, URL) ? '- [x]' : '- [ ]'} `; + issueBody += `${URL}`; + issueBody += ` - ${mergerMention}`; + issueBody += '\r\n'; + }); + issueBody += '\r\n\r\n'; + } - // Deploy blockers - if (!_.isEmpty(deployBlockers)) { - issueBody += '**Deploy Blockers:**\r\n'; - _.each(sortedDeployBlockers, (URL) => { - issueBody += _.contains(resolvedDeployBlockers, URL) ? '- [x] ' : '- [ ] '; - issueBody += URL; - issueBody += '\r\n'; - }); - issueBody += '\r\n\r\n'; - } + // Deploy blockers + if (!_.isEmpty(deployBlockers)) { + issueBody += '**Deploy Blockers:**\r\n'; + _.each(sortedDeployBlockers, (URL) => { + issueBody += _.contains(resolvedDeployBlockers, URL) ? '- [x] ' : '- [ ] '; + issueBody += URL; + issueBody += '\r\n'; + }); + issueBody += '\r\n\r\n'; + } - issueBody += '**Deployer verifications:**'; - // eslint-disable-next-line max-len - issueBody += `\r\n- [${ - isTimingDashboardChecked ? 'x' : ' ' - }] I checked the [App Timing Dashboard](https://graphs.expensify.com/grafana/d/yj2EobAGz/app-timing?orgId=1) and verified this release does not cause a noticeable performance regression.`; - // eslint-disable-next-line max-len - issueBody += `\r\n- [${ - isFirebaseChecked ? 'x' : ' ' - }] I checked [Firebase Crashlytics](https://console.firebase.google.com/u/0/project/expensify-chat/crashlytics/app/android:com.expensify.chat/issues?state=open&time=last-seven-days&tag=all) and verified that this release does not introduce any new crashes. More detailed instructions on this verification can be found [here](https://stackoverflowteams.com/c/expensify/questions/15095/15096).`; - // eslint-disable-next-line max-len - issueBody += `\r\n- [${isGHStatusChecked ? 'x' : ' '}] I checked [GitHub Status](https://www.githubstatus.com/) and verified there is no reported incident with Actions.`; - - issueBody += '\r\n\r\ncc @Expensify/applauseleads\r\n'; - return issueBody; + issueBody += '**Deployer verifications:**'; + // eslint-disable-next-line max-len + issueBody += `\r\n- [${ + isTimingDashboardChecked ? 'x' : ' ' + }] I checked the [App Timing Dashboard](https://graphs.expensify.com/grafana/d/yj2EobAGz/app-timing?orgId=1) and verified this release does not cause a noticeable performance regression.`; + // eslint-disable-next-line max-len + issueBody += `\r\n- [${ + isFirebaseChecked ? 'x' : ' ' + }] I checked [Firebase Crashlytics](https://console.firebase.google.com/u/0/project/expensify-chat/crashlytics/app/android:com.expensify.chat/issues?state=open&time=last-seven-days&tag=all) and verified that this release does not introduce any new crashes. More detailed instructions on this verification can be found [here](https://stackoverflowteams.com/c/expensify/questions/15095/15096).`; + // eslint-disable-next-line max-len + issueBody += `\r\n- [${isGHStatusChecked ? 'x' : ' '}] I checked [GitHub Status](https://www.githubstatus.com/) and verified there is no reported incident with Actions.`; + + issueBody += '\r\n\r\ncc @Expensify/applauseleads\r\n'; + const issueAssignees = _.uniq(_.values(internalQAPRMap)); + const issue = {issueBody, issueAssignees}; + return issue; + }); }) .catch((err) => console.warn('Error generating StagingDeployCash issue body! Continuing...', err)); } @@ -393,6 +397,20 @@ class GithubUtils { .catch((err) => console.error('Failed to get PR list', err)); } + /** + * @param {Number} pullRequestNumber + * @returns {Promise} + */ + static getPullRequestMergerLogin(pullRequestNumber) { + return this.octokit.pulls + .get({ + owner: CONST.GITHUB_OWNER, + repo: CONST.APP_REPO, + pull_number: pullRequestNumber, + }) + .then(({data: pullRequest}) => pullRequest.merged_by.login); + } + /** * @param {Number} pullRequestNumber * @returns {Promise} diff --git a/.github/libs/GithubUtils.js b/.github/libs/GithubUtils.js index 0cd407c78153..47ad2440c165 100644 --- a/.github/libs/GithubUtils.js +++ b/.github/libs/GithubUtils.js @@ -222,7 +222,7 @@ class GithubUtils { } /** - * Generate the issue body for a StagingDeployCash. + * Generate the issue body and assignees for a StagingDeployCash. * * @param {String} tag * @param {Array} PRList - The list of PR URLs which are included in this StagingDeployCash @@ -235,7 +235,7 @@ class GithubUtils { * @param {Boolean} [isGHStatusChecked] * @returns {Promise} */ - static generateStagingDeployCashBody( + static generateStagingDeployCashBodyAndAssignees( tag, PRList, verifiedPRList = [], @@ -248,85 +248,89 @@ class GithubUtils { ) { return this.fetchAllPullRequests(_.map(PRList, this.getPullRequestNumberFromURL)) .then((data) => { - // The format of this map is following: - // { - // 'https://github.com/Expensify/App/pull/9641': [ 'PauloGasparSv', 'kidroca' ], - // 'https://github.com/Expensify/App/pull/9642': [ 'mountiny', 'kidroca' ] - // } - const internalQAPRMap = _.reduce( - _.filter(data, (pr) => !_.isEmpty(_.findWhere(pr.labels, {name: CONST.LABELS.INTERNAL_QA}))), - (map, pr) => { - // eslint-disable-next-line no-param-reassign - map[pr.html_url] = _.compact(_.pluck(pr.assignees, 'login')); - return map; - }, - {}, - ); - console.log('Found the following Internal QA PRs:', internalQAPRMap); - - const noQAPRs = _.pluck( - _.filter(data, (PR) => /\[No\s?QA]/i.test(PR.title)), - 'html_url', - ); - console.log('Found the following NO QA PRs:', noQAPRs); - const verifiedOrNoQAPRs = _.union(verifiedPRList, noQAPRs); - - const sortedPRList = _.chain(PRList).difference(_.keys(internalQAPRMap)).unique().sortBy(GithubUtils.getPullRequestNumberFromURL).value(); - const sortedDeployBlockers = _.sortBy(_.unique(deployBlockers), GithubUtils.getIssueOrPullRequestNumberFromURL); - - // Tag version and comparison URL - // eslint-disable-next-line max-len - let issueBody = `**Release Version:** \`${tag}\`\r\n**Compare Changes:** https://github.com/Expensify/App/compare/production...staging\r\n`; - - // PR list - if (!_.isEmpty(sortedPRList)) { - issueBody += '\r\n**This release contains changes from the following pull requests:**\r\n'; - _.each(sortedPRList, (URL) => { - issueBody += _.contains(verifiedOrNoQAPRs, URL) ? '- [x]' : '- [ ]'; - issueBody += ` ${URL}\r\n`; - }); - issueBody += '\r\n\r\n'; - } - - // Internal QA PR list - if (!_.isEmpty(internalQAPRMap)) { - console.log('Found the following verified Internal QA PRs:', resolvedInternalQAPRs); - issueBody += '**Internal QA:**\r\n'; - _.each(internalQAPRMap, (assignees, URL) => { - const assigneeMentions = _.reduce(assignees, (memo, assignee) => `${memo} @${assignee}`, ''); - issueBody += `${_.contains(resolvedInternalQAPRs, URL) ? '- [x]' : '- [ ]'} `; - issueBody += `${URL}`; - issueBody += ` -${assigneeMentions}`; - issueBody += '\r\n'; - }); - issueBody += '\r\n\r\n'; - } - - // Deploy blockers - if (!_.isEmpty(deployBlockers)) { - issueBody += '**Deploy Blockers:**\r\n'; - _.each(sortedDeployBlockers, (URL) => { - issueBody += _.contains(resolvedDeployBlockers, URL) ? '- [x] ' : '- [ ] '; - issueBody += URL; - issueBody += '\r\n'; - }); - issueBody += '\r\n\r\n'; - } - - issueBody += '**Deployer verifications:**'; - // eslint-disable-next-line max-len - issueBody += `\r\n- [${ - isTimingDashboardChecked ? 'x' : ' ' - }] I checked the [App Timing Dashboard](https://graphs.expensify.com/grafana/d/yj2EobAGz/app-timing?orgId=1) and verified this release does not cause a noticeable performance regression.`; - // eslint-disable-next-line max-len - issueBody += `\r\n- [${ - isFirebaseChecked ? 'x' : ' ' - }] I checked [Firebase Crashlytics](https://console.firebase.google.com/u/0/project/expensify-chat/crashlytics/app/android:com.expensify.chat/issues?state=open&time=last-seven-days&tag=all) and verified that this release does not introduce any new crashes. More detailed instructions on this verification can be found [here](https://stackoverflowteams.com/c/expensify/questions/15095/15096).`; - // eslint-disable-next-line max-len - issueBody += `\r\n- [${isGHStatusChecked ? 'x' : ' '}] I checked [GitHub Status](https://www.githubstatus.com/) and verified there is no reported incident with Actions.`; - - issueBody += '\r\n\r\ncc @Expensify/applauseleads\r\n'; - return issueBody; + const internalQAPRs = _.filter(data, (pr) => !_.isEmpty(_.findWhere(pr.labels, {name: CONST.LABELS.INTERNAL_QA}))); + return Promise.all(_.map(internalQAPRs, (pr) => this.getPullRequestMergerLogin(pr.number).then((mergerLogin) => ({url: pr.html_url, mergerLogin})))).then((results) => { + // The format of this map is following: + // { + // 'https://github.com/Expensify/App/pull/9641': 'PauloGasparSv', + // 'https://github.com/Expensify/App/pull/9642': 'mountiny' + // } + const internalQAPRMap = _.reduce( + results, + (acc, {url, mergerLogin}) => { + acc[url] = mergerLogin; + return acc; + }, + {}, + ); + console.log('Found the following Internal QA PRs:', internalQAPRMap); + + const noQAPRs = _.pluck( + _.filter(data, (PR) => /\[No\s?QA]/i.test(PR.title)), + 'html_url', + ); + console.log('Found the following NO QA PRs:', noQAPRs); + const verifiedOrNoQAPRs = _.union(verifiedPRList, noQAPRs); + + const sortedPRList = _.chain(PRList).difference(_.keys(internalQAPRMap)).unique().sortBy(GithubUtils.getPullRequestNumberFromURL).value(); + const sortedDeployBlockers = _.sortBy(_.unique(deployBlockers), GithubUtils.getIssueOrPullRequestNumberFromURL); + + // Tag version and comparison URL + // eslint-disable-next-line max-len + let issueBody = `**Release Version:** \`${tag}\`\r\n**Compare Changes:** https://github.com/Expensify/App/compare/production...staging\r\n`; + + // PR list + if (!_.isEmpty(sortedPRList)) { + issueBody += '\r\n**This release contains changes from the following pull requests:**\r\n'; + _.each(sortedPRList, (URL) => { + issueBody += _.contains(verifiedOrNoQAPRs, URL) ? '- [x]' : '- [ ]'; + issueBody += ` ${URL}\r\n`; + }); + issueBody += '\r\n\r\n'; + } + + // Internal QA PR list + if (!_.isEmpty(internalQAPRMap)) { + console.log('Found the following verified Internal QA PRs:', resolvedInternalQAPRs); + issueBody += '**Internal QA:**\r\n'; + _.each(internalQAPRMap, (merger, URL) => { + const mergerMention = `@${merger}`; + issueBody += `${_.contains(resolvedInternalQAPRs, URL) ? '- [x]' : '- [ ]'} `; + issueBody += `${URL}`; + issueBody += ` - ${mergerMention}`; + issueBody += '\r\n'; + }); + issueBody += '\r\n\r\n'; + } + + // Deploy blockers + if (!_.isEmpty(deployBlockers)) { + issueBody += '**Deploy Blockers:**\r\n'; + _.each(sortedDeployBlockers, (URL) => { + issueBody += _.contains(resolvedDeployBlockers, URL) ? '- [x] ' : '- [ ] '; + issueBody += URL; + issueBody += '\r\n'; + }); + issueBody += '\r\n\r\n'; + } + + issueBody += '**Deployer verifications:**'; + // eslint-disable-next-line max-len + issueBody += `\r\n- [${ + isTimingDashboardChecked ? 'x' : ' ' + }] I checked the [App Timing Dashboard](https://graphs.expensify.com/grafana/d/yj2EobAGz/app-timing?orgId=1) and verified this release does not cause a noticeable performance regression.`; + // eslint-disable-next-line max-len + issueBody += `\r\n- [${ + isFirebaseChecked ? 'x' : ' ' + }] I checked [Firebase Crashlytics](https://console.firebase.google.com/u/0/project/expensify-chat/crashlytics/app/android:com.expensify.chat/issues?state=open&time=last-seven-days&tag=all) and verified that this release does not introduce any new crashes. More detailed instructions on this verification can be found [here](https://stackoverflowteams.com/c/expensify/questions/15095/15096).`; + // eslint-disable-next-line max-len + issueBody += `\r\n- [${isGHStatusChecked ? 'x' : ' '}] I checked [GitHub Status](https://www.githubstatus.com/) and verified there is no reported incident with Actions.`; + + issueBody += '\r\n\r\ncc @Expensify/applauseleads\r\n'; + const issueAssignees = _.uniq(_.values(internalQAPRMap)); + const issue = {issueBody, issueAssignees}; + return issue; + }); }) .catch((err) => console.warn('Error generating StagingDeployCash issue body! Continuing...', err)); } @@ -360,6 +364,20 @@ class GithubUtils { .catch((err) => console.error('Failed to get PR list', err)); } + /** + * @param {Number} pullRequestNumber + * @returns {Promise} + */ + static getPullRequestMergerLogin(pullRequestNumber) { + return this.octokit.pulls + .get({ + owner: CONST.GITHUB_OWNER, + repo: CONST.APP_REPO, + pull_number: pullRequestNumber, + }) + .then(({data: pullRequest}) => pullRequest.merged_by.login); + } + /** * @param {Number} pullRequestNumber * @returns {Promise} diff --git a/android/app/build.gradle b/android/app/build.gradle index 9c5db608a846..62e30858e73c 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -98,8 +98,8 @@ android { minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion multiDexEnabled rootProject.ext.multiDexEnabled - versionCode 1001045503 - versionName "1.4.55-3" + versionCode 1001045602 + versionName "1.4.56-2" } flavorDimensions "default" diff --git a/docs/articles/expensify-classic/expenses/Add-expenses-to-a-report.md b/docs/articles/expensify-classic/expenses/Add-expenses-to-a-report.md new file mode 100644 index 000000000000..96427a60d87f --- /dev/null +++ b/docs/articles/expensify-classic/expenses/Add-expenses-to-a-report.md @@ -0,0 +1,18 @@ +--- +title: Add expenses to a report +description: Add expenses to a report to submit them for approval +--- +
+ +To submit expenses for approval, they must be added to a report. + +1. Click the **Expenses** tab. +2. Find the expenses you want to add to the report by searching through the table of expenses and/or using the sort filters. +3. Select the expenses by checking the box to the left of each expense or selecting them all. +4. Click **Add to Report** in the right corner and select either: + - **Auto-Report**: Automatically adds the expenses to an open report, or creates a new report if there are no open reports + - **New Report**: Creates a new report for the expenses + - **None**: Ensures none of the selected expenses are attached to a report (as long as the report has not already been submitted) + - **Existing Report**: Adds the expenses to the selected report + +
diff --git a/docs/articles/expensify-classic/expenses/Export-expenses.md b/docs/articles/expensify-classic/expenses/Export-expenses.md new file mode 100644 index 000000000000..14c1532f84b5 --- /dev/null +++ b/docs/articles/expensify-classic/expenses/Export-expenses.md @@ -0,0 +1,13 @@ +--- +title: Export expenses +description: Export expenses to a CSV +--- +
+ +1. Click the **Expenses** tab. +2. Select the expenses you want to export by checking the box to the left of each expense or selecting them all. +3. Click **Export To** in the right corner and select either: + - **Default CSV**: Use Expensify’s default template + - **Create new CSV export layout**: Create your own custom CSV template + +
diff --git a/docs/articles/expensify-classic/integrations/HR-integrations/Workday.md b/docs/articles/expensify-classic/integrations/HR-integrations/Workday.md index ecdea4699ee0..47e7a5b5382a 100644 --- a/docs/articles/expensify-classic/integrations/HR-integrations/Workday.md +++ b/docs/articles/expensify-classic/integrations/HR-integrations/Workday.md @@ -34,7 +34,7 @@ In order to complete the steps below, you'll need a Workday System Administrator 4. Search and select "security group membership and access". 5. Search for the security group you just created. 6. Click the ellipsis, then **Security Group > Maintain Domain Permissions for Security Group**. -7. Under **Integration Permissions**, add "External Account Provisioning" to **Domain Security Workspaces permitting Put access** and "Worker Data: Workers" to **Domain Security Workspaces permitting Get access**. +7. Head to Integration Permissions and add **Get access** for “External Account Provisioning” and “Worker Data: Workers” under Domain Security Workspaces. 8. Click **OK** and **Done**. 9. Search **Activate Pending Security Workspace Changes** and complete the task for activating the security workspace change, adding a comment if required and checking the **Confirmed** check-box. diff --git a/docs/articles/expensify-classic/workspaces/Assign-billing-owner-and-payment-account.md b/docs/articles/expensify-classic/workspaces/Assign-billing-owner-and-payment-account.md new file mode 100644 index 000000000000..9037e58661d1 --- /dev/null +++ b/docs/articles/expensify-classic/workspaces/Assign-billing-owner-and-payment-account.md @@ -0,0 +1,30 @@ +--- +title: Assign billing owner and payment account +description: Determine who will cover the cost of the workspace and link a payment method +--- +
+ +The person who creates a workspace will automatically be responsible for the billing for that workspace. However, the existing billing owner can transfer the workspace’s billing ownership to any Admin on the workspace. + +{% include info.html %} +There can only be one billing owner at a time. Assigning a new billing owner will automatically un-assign the existing billing owner. However, billing owners are also workspace admins by default, and the previous billing owner will remain a workspace admin unless manually updated. +{% include end-info.html %} + +# Assign a new billing owner + +To assign a new billing owner, **the person who will take over responsibility for the workspace billing must complete the following process**: + +1. Hover over Settings, then click **Workspaces**. +2. Click the desired workspace name. +3. Under Workspace Overview, click **Take Over Billing**. + +# Add or update payment account + +Once you take over billing for a workspace, you must add a payment method to your account. + +1. Hover over Settings, then click **Account**. +2. Click the **Payments** tab. +3. Scroll down to the Payment Details sections and click **Add Payment Card**. +4. Enter your credit or debit card information and click **Accept terms, add payment card, and pay $0.00** (the box will only show a balance if one is due). + +
diff --git a/docs/articles/expensify-classic/workspaces/Create-a-group-workspace.md b/docs/articles/expensify-classic/workspaces/Create-a-group-workspace.md new file mode 100644 index 000000000000..b0b016afbcbb --- /dev/null +++ b/docs/articles/expensify-classic/workspaces/Create-a-group-workspace.md @@ -0,0 +1,24 @@ +--- +title: Create a group workspace +description: Create a workspace for your team's expense reports +--- +
+ +A workspace is the set of rules, settings, and spending limits for expense reports in your organization. This includes the unique expense categories and tags, budgets, currency and tax settings, etc. that all workspace members will use. A workspace also defines the approval workflow for your employees, as well as the accounting connection if using an accounting software integration. + +Here are a couple examples of when you’d want to create different workspaces: + +- You have employees with expense reports in different currencies. For example, you may have a workspace for employees who live in the US and submit their reports in USD and a workspace for employees who live in Canada and submit in CAD. +- You want to limit specific groups of people to their own set of expense coding options (categories/tags) then they can separate their employees by Sales, Marketing, Support, etc. + +To create a group workspace, + +1. Hover over Settings, then click **Workspaces**. +2. Click the **Group** tab on the left. +3. Click **New Workspace**. +4. Enter the workspace name and select a workspace type. + - **Collect**: Ideal for small groups who only need basic features like expense approvals, reimbursement, corporate card management, and integration options. + - **Control**: For groups that need a deeper level of control and configurations, like multi-stage approval workflows, corporate card management, integrations, and more. This is the most popular option. +5. Set up your workspace details including the workspace name, expense rules, categories, and more. + +
diff --git a/docs/articles/expensify-classic/workspaces/Set-time-and-distance-rates.md b/docs/articles/expensify-classic/workspaces/Set-time-and-distance-rates.md new file mode 100644 index 000000000000..e81e05446379 --- /dev/null +++ b/docs/articles/expensify-classic/workspaces/Set-time-and-distance-rates.md @@ -0,0 +1,24 @@ +--- +title: Set time and distance rates +description: Set rates for hourly and mileage expenses +--- +
+ +You can set rates for your workspace’s hourly billable and mileage expenses. + +1. Hover over Settings, then click **Workspaces**. +2. Click the **Group** tab on the left. +3. Click the desired workspace name. +4. Click the **Expenses** tab on the left. +5. Scroll down to the time or distance section and set your rates. + - For distance: + - If desired, adjust your unit (miles or kilometers) and your default category. These options will apply to all of your distance rates. + - To add a new rate, click **Add a Mileage Rate**. + - To edit an existing rate, + - Click the toggle to enable or disable the rate. + - Click the name or rate field to edit them. + - For time, + - Click the Enable toggle to enable hourly rate expenses. + - Enter the default hourly rate. + +
diff --git a/docs/articles/expensify-classic/workspaces/Set-up-your-individual-workspace.md b/docs/articles/expensify-classic/workspaces/Set-up-your-individual-workspace.md new file mode 100644 index 000000000000..c8be9a2728d5 --- /dev/null +++ b/docs/articles/expensify-classic/workspaces/Set-up-your-individual-workspace.md @@ -0,0 +1,20 @@ +--- +title: Set up your individual workspace +description: Capture your personal expenses +--- +
+ +All Expensify accounts come with an individual workspace where you can track your personal expenses. If you want to connect your personal expenses to an accounting or travel integration, you can create a group workspace—even if you will be the only person in the group. + +To set up your individual workspace, + +1. Hover over Settings, then click **Workspaces**. +2. Click the **Individual** tab on the left. +3. Select the policy type that best fits your needs. +4. Set up your workspace details including the workspace name, expense rules, categories, and more. + +{% include info.html %} +You can create multiple group workspaces, but you can only create one individual workspace. +{% include end-info.html %} + +
diff --git a/docs/redirects.csv b/docs/redirects.csv index df4e2a45dce3..7539a2777d92 100644 --- a/docs/redirects.csv +++ b/docs/redirects.csv @@ -66,3 +66,4 @@ https://help.expensify.com/articles/expensify-classic/expensify-billing/Individu https://help.expensify.com/articles/expensify-classic/settings/Merge-Accounts,https://help.expensify.com/articles/expensify-classic/settings/account-settings/Merge-accounts https://help.expensify.com/articles/expensify-classic/settings/Preferences,https://help.expensify.com/expensify-classic/hubs/settings/account-settings https://help.expensify.com/articles/expensify-classic/getting-started/support/Your-Expensify-Account-Manager,https://use.expensify.com/support +https://help.expensify.com/articles/expensify-classic/settings/Copilot,https://help.expensify.com/expensify-classic/hubs/copilots-and-delegates/ diff --git a/ios/NewExpensify/Info.plist b/ios/NewExpensify/Info.plist index 5e2ba1fcd614..a962c69f0bc6 100644 --- a/ios/NewExpensify/Info.plist +++ b/ios/NewExpensify/Info.plist @@ -19,7 +19,7 @@ CFBundlePackageType APPL CFBundleShortVersionString - 1.4.55 + 1.4.56 CFBundleSignature ???? CFBundleURLTypes @@ -40,7 +40,7 @@ CFBundleVersion - 1.4.55.3 + 1.4.56.2 ITSAppUsesNonExemptEncryption LSApplicationQueriesSchemes diff --git a/ios/NewExpensifyTests/Info.plist b/ios/NewExpensifyTests/Info.plist index 69472200e46d..9f20eb574abc 100644 --- a/ios/NewExpensifyTests/Info.plist +++ b/ios/NewExpensifyTests/Info.plist @@ -15,10 +15,10 @@ CFBundlePackageType BNDL CFBundleShortVersionString - 1.4.55 + 1.4.56 CFBundleSignature ???? CFBundleVersion - 1.4.55.3 + 1.4.56.2 diff --git a/ios/NotificationServiceExtension/Info.plist b/ios/NotificationServiceExtension/Info.plist index 008ca16909b0..2319ff879a03 100644 --- a/ios/NotificationServiceExtension/Info.plist +++ b/ios/NotificationServiceExtension/Info.plist @@ -11,9 +11,9 @@ CFBundleName $(PRODUCT_NAME) CFBundleShortVersionString - 1.4.55 + 1.4.56 CFBundleVersion - 1.4.55.3 + 1.4.56.2 NSExtension NSExtensionPointIdentifier diff --git a/package-lock.json b/package-lock.json index 4bff5eaf6eb8..fcebc3cd46dd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "new.expensify", - "version": "1.4.55-3", + "version": "1.4.56-2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "new.expensify", - "version": "1.4.55-3", + "version": "1.4.56-2", "hasInstallScript": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index 53eb229d7b85..ab1a3ecc7d64 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "new.expensify", - "version": "1.4.55-3", + "version": "1.4.56-2", "author": "Expensify, Inc.", "homepage": "https://new.expensify.com", "description": "New Expensify is the next generation of Expensify: a reimagination of payments based atop a foundation of chat.", diff --git a/src/CONST.ts b/src/CONST.ts index a558361f56db..8e7c8beac275 100755 --- a/src/CONST.ts +++ b/src/CONST.ts @@ -630,6 +630,7 @@ const CONST = { EXPORTEDTOQUICKBOOKS: 'EXPORTEDTOQUICKBOOKS', // OldDot Action FORWARDED: 'FORWARDED', // OldDot Action HOLD: 'HOLD', + HOLDCOMMENT: 'HOLDCOMMENT', IOU: 'IOU', INTEGRATIONSMESSAGE: 'INTEGRATIONSMESSAGE', // OldDot Action MANAGERATTACHRECEIPT: 'MANAGERATTACHRECEIPT', // OldDot Action @@ -1679,7 +1680,7 @@ const CONST = { POLICY_ID_FROM_PATH: /\/w\/([a-zA-Z0-9]+)(\/|$)/, - SHORT_MENTION: new RegExp(`@[\\w\\-\\+\\'#]+(?:\\.[\\w\\-\\'\\+]+)*`, 'gim'), + SHORT_MENTION: new RegExp(`@[\\w\\-\\+\\'#@]+(?:\\.[\\w\\-\\'\\+]+)*`, 'gim'), }, PRONOUNS: { @@ -4133,6 +4134,22 @@ const CONST = { SESSION_STORAGE_KEYS: { INITIAL_URL: 'INITIAL_URL', }, + DEFAULT_TAX: { + defaultExternalID: 'id_TAX_EXEMPT', + defaultValue: '0%', + foreignTaxDefault: 'id_TAX_EXEMPT', + name: 'Tax', + taxes: { + id_TAX_EXEMPT: { + name: 'Tax exempt', + value: '0%', + }, + id_TAX_RATE_1: { + name: 'Tax Rate 1', + value: '5%', + }, + }, + }, } as const; type Country = keyof typeof CONST.ALL_COUNTRIES; diff --git a/src/ROUTES.ts b/src/ROUTES.ts index a49df16b570a..c216d5ac288c 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -304,13 +304,10 @@ const ROUTES = { route: ':iouType/new/receipt/:reportID?', getRoute: (iouType: string, reportID = '') => `${iouType}/new/receipt/${reportID}` as const, }, - MONEY_REQUEST_DISTANCE: { - route: ':iouType/new/address/:reportID?', - getRoute: (iouType: string, reportID = '') => `${iouType}/new/address/${reportID}` as const, - }, MONEY_REQUEST_CREATE: { - route: 'create/:iouType/start/:transactionID/:reportID', - getRoute: (iouType: ValueOf, transactionID: string, reportID: string) => `create/${iouType}/start/${transactionID}/${reportID}` as const, + route: ':action/:iouType/start/:transactionID/:reportID', + getRoute: (action: ValueOf, iouType: ValueOf, transactionID: string, reportID: string) => + `create/${iouType}/start/${transactionID}/${reportID}` as const, }, MONEY_REQUEST_STEP_CONFIRMATION: { route: 'create/:iouType/confirmation/:transactionID/:reportID', @@ -352,9 +349,9 @@ const ROUTES = { getUrlWithBackToParam(`${action}/${iouType}/description/${transactionID}/${reportID}${reportActionID ? `/${reportActionID}` : ''}`, backTo), }, MONEY_REQUEST_STEP_DISTANCE: { - route: 'create/:iouType/distance/:transactionID/:reportID', - getRoute: (iouType: ValueOf, transactionID: string, reportID: string, backTo = '') => - getUrlWithBackToParam(`create/${iouType}/distance/${transactionID}/${reportID}`, backTo), + route: ':action/:iouType/distance/:transactionID/:reportID', + getRoute: (action: ValueOf, iouType: ValueOf, transactionID: string, reportID: string, backTo = '') => + getUrlWithBackToParam(`${action}/${iouType}/distance/${transactionID}/${reportID}`, backTo), }, MONEY_REQUEST_STEP_MERCHANT: { route: ':action/:iouType/merchant/:transactionID/:reportID', @@ -395,16 +392,19 @@ const ROUTES = { getRoute: (iouType: ValueOf, iouRequestType: ValueOf) => `start/${iouType}/${iouRequestType}` as const, }, MONEY_REQUEST_CREATE_TAB_DISTANCE: { - route: 'create/:iouType/start/:transactionID/:reportID/distance', - getRoute: (iouType: ValueOf, transactionID: string, reportID: string) => `create/${iouType}/start/${transactionID}/${reportID}/distance` as const, + route: ':action/:iouType/start/:transactionID/:reportID/distance', + getRoute: (action: ValueOf, iouType: ValueOf, transactionID: string, reportID: string) => + `create/${iouType}/start/${transactionID}/${reportID}/distance` as const, }, MONEY_REQUEST_CREATE_TAB_MANUAL: { - route: 'create/:iouType/start/:transactionID/:reportID/manual', - getRoute: (iouType: ValueOf, transactionID: string, reportID: string) => `create/${iouType}/start/${transactionID}/${reportID}/manual` as const, + route: ':action/:iouType/start/:transactionID/:reportID/manual', + getRoute: (action: ValueOf, iouType: ValueOf, transactionID: string, reportID: string) => + `create/${iouType}/start/${transactionID}/${reportID}/manual` as const, }, MONEY_REQUEST_CREATE_TAB_SCAN: { - route: 'create/:iouType/start/:transactionID/:reportID/scan', - getRoute: (iouType: ValueOf, transactionID: string, reportID: string) => `create/${iouType}/start/${transactionID}/${reportID}/scan` as const, + route: ':action/:iouType/start/:transactionID/:reportID/scan', + getRoute: (action: ValueOf, iouType: ValueOf, transactionID: string, reportID: string) => + `create/${iouType}/start/${transactionID}/${reportID}/scan` as const, }, IOU_REQUEST: 'request/new', diff --git a/src/SCREENS.ts b/src/SCREENS.ts index 9c33c7e63d7d..82fef0383918 100644 --- a/src/SCREENS.ts +++ b/src/SCREENS.ts @@ -155,7 +155,6 @@ const SCREENS = { CURRENCY: 'Money_Request_Currency', WAYPOINT: 'Money_Request_Waypoint', EDIT_WAYPOINT: 'Money_Request_Edit_Waypoint', - DISTANCE: 'Money_Request_Distance', RECEIPT: 'Money_Request_Receipt', }, diff --git a/src/components/AddressSearch/index.tsx b/src/components/AddressSearch/index.tsx index 9901ff9243f9..d0aa2e206eb2 100644 --- a/src/components/AddressSearch/index.tsx +++ b/src/components/AddressSearch/index.tsx @@ -455,3 +455,5 @@ function AddressSearch( AddressSearch.displayName = 'AddressSearchWithRef'; export default forwardRef(AddressSearch); + +export type {AddressSearchProps}; diff --git a/src/components/AddressSearch/types.ts b/src/components/AddressSearch/types.ts index 27e068cd1777..efbcc6374341 100644 --- a/src/components/AddressSearch/types.ts +++ b/src/components/AddressSearch/types.ts @@ -96,4 +96,4 @@ type AddressSearchProps = { type IsCurrentTargetInsideContainerType = (event: FocusEvent | NativeSyntheticEvent, containerRef: RefObject) => boolean; -export type {CurrentLocationButtonProps, AddressSearchProps, RenamedInputKeysProps, IsCurrentTargetInsideContainerType}; +export type {CurrentLocationButtonProps, AddressSearchProps, RenamedInputKeysProps, IsCurrentTargetInsideContainerType, StreetValue}; diff --git a/src/components/AttachmentModal.tsx b/src/components/AttachmentModal.tsx index 0c047ce52dc8..442b3cd864d6 100755 --- a/src/components/AttachmentModal.tsx +++ b/src/components/AttachmentModal.tsx @@ -549,7 +549,7 @@ function AttachmentModal({ // @ts-expect-error TODO: Remove this once Attachments (https://github.com/Expensify/App/issues/24969) is migrated to TypeScript. containerStyles={[styles.mh5]} source={sourceForAttachmentView} - isAuthTokenRequired={isAuthTokenRequired} + isAuthTokenRequired={isAuthTokenRequiredState} file={file} onToggleKeyboard={updateConfirmButtonVisibility} isWorkspaceAvatar={isWorkspaceAvatar} diff --git a/src/components/Banner.tsx b/src/components/Banner.tsx index 56fe7c4d0b42..a46b37c986ba 100644 --- a/src/components/Banner.tsx +++ b/src/components/Banner.tsx @@ -109,3 +109,5 @@ function Banner({text, onClose, onPress, containerStyles, textStyles, shouldRend Banner.displayName = 'Banner'; export default memo(Banner); + +export type {BannerProps}; diff --git a/src/components/Button/index.tsx b/src/components/Button/index.tsx index 3abc5b17ae80..3b05d22fe6c4 100644 --- a/src/components/Button/index.tsx +++ b/src/components/Button/index.tsx @@ -352,3 +352,5 @@ function Button( Button.displayName = 'Button'; export default withNavigationFallback(React.forwardRef(Button)); + +export type {ButtonProps}; diff --git a/src/components/ButtonWithDropdownMenu/index.tsx b/src/components/ButtonWithDropdownMenu/index.tsx index 5f426f77b731..e89026137b67 100644 --- a/src/components/ButtonWithDropdownMenu/index.tsx +++ b/src/components/ButtonWithDropdownMenu/index.tsx @@ -116,7 +116,7 @@ function ButtonWithDropdownMenu({ success={success} ref={buttonRef} pressOnEnter={pressOnEnter} - isDisabled={isDisabled} + isDisabled={isDisabled || !!options[0].disabled} style={[styles.w100, style]} isLoading={isLoading} text={selectedItem.text} diff --git a/src/components/ButtonWithDropdownMenu/types.ts b/src/components/ButtonWithDropdownMenu/types.ts index 83100788761f..87db9a29d827 100644 --- a/src/components/ButtonWithDropdownMenu/types.ts +++ b/src/components/ButtonWithDropdownMenu/types.ts @@ -22,6 +22,7 @@ type DropdownOption = { iconHeight?: number; iconDescription?: string; onSelected?: () => void; + disabled?: boolean; }; type ButtonWithDropdownMenuProps = { diff --git a/src/components/DistanceRequest/index.tsx b/src/components/DistanceRequest/index.tsx deleted file mode 100644 index f9e8c0be12ff..000000000000 --- a/src/components/DistanceRequest/index.tsx +++ /dev/null @@ -1,320 +0,0 @@ -import type {RouteProp} from '@react-navigation/native'; -import lodashIsEqual from 'lodash/isEqual'; -import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; -import {View} from 'react-native'; -// eslint-disable-next-line no-restricted-imports -import type {ScrollView} from 'react-native'; -import {withOnyx} from 'react-native-onyx'; -import type {OnyxEntry} from 'react-native-onyx'; -import FullPageNotFoundView from '@components/BlockingViews/FullPageNotFoundView'; -import Button from '@components/Button'; -import DotIndicatorMessage from '@components/DotIndicatorMessage'; -import DraggableList from '@components/DraggableList'; -import type {DraggableListData} from '@components/DraggableList/types'; -import HeaderWithBackButton from '@components/HeaderWithBackButton'; -import ScreenWrapper from '@components/ScreenWrapper'; -import useLocalize from '@hooks/useLocalize'; -import useNetwork from '@hooks/useNetwork'; -import usePrevious from '@hooks/usePrevious'; -import useThemeStyles from '@hooks/useThemeStyles'; -import * as ErrorUtils from '@libs/ErrorUtils'; -import * as IOUUtils from '@libs/IOUUtils'; -import Navigation from '@libs/Navigation/Navigation'; -import * as TransactionUtils from '@libs/TransactionUtils'; -import * as MapboxToken from '@userActions/MapboxToken'; -import * as TransactionUserActions from '@userActions/Transaction'; -import * as TransactionEdit from '@userActions/TransactionEdit'; -import CONST from '@src/CONST'; -import ONYXKEYS from '@src/ONYXKEYS'; -import ROUTES from '@src/ROUTES'; -import type {Report, Transaction} from '@src/types/onyx'; -import type {WaypointCollection} from '@src/types/onyx/Transaction'; -import {isEmptyObject} from '@src/types/utils/EmptyObject'; -import DistanceRequestFooter from './DistanceRequestFooter'; -import DistanceRequestRenderItem from './DistanceRequestRenderItem'; - -type DistanceRequestOnyxProps = { - transaction: OnyxEntry; -}; - -type DistanceRequestProps = DistanceRequestOnyxProps & { - /** The TransactionID of this request */ - transactionID?: string; - - /** The report to which the distance request is associated */ - report: OnyxEntry; - - /** Are we editing an existing distance request, or creating a new one? */ - isEditingRequest?: boolean; - - /** Are we editing the distance while creating a new distance request */ - isEditingNewRequest?: boolean; - - /** Called on submit of this page */ - onSubmit: (waypoints?: WaypointCollection) => void; - - /** React Navigation route */ - route: RouteProp<{ - /** Params from the route */ - params: { - /** The type of IOU report, i.e. bill, request, send */ - iouType: string; - /** The report ID of the IOU */ - reportID: string; - }; - }>; -}; - -function DistanceRequest({transactionID = '', report, transaction, route, isEditingRequest = false, isEditingNewRequest = false, onSubmit}: DistanceRequestProps) { - const styles = useThemeStyles(); - const {isOffline} = useNetwork(); - const {translate} = useLocalize(); - - const [optimisticWaypoints, setOptimisticWaypoints] = useState(); - const [hasError, setHasError] = useState(false); - const reportID = report?.reportID ?? ''; - const waypoints: WaypointCollection = useMemo(() => optimisticWaypoints ?? transaction?.comment?.waypoints ?? {waypoint0: {}, waypoint1: {}}, [optimisticWaypoints, transaction]); - const waypointsList = Object.keys(waypoints); - const iouType = route?.params?.iouType ?? ''; - const previousWaypoints = usePrevious(waypoints); - const numberOfWaypoints = Object.keys(waypoints).length; - const numberOfPreviousWaypoints = Object.keys(previousWaypoints).length; - const scrollViewRef = useRef(null); - - const isLoadingRoute = transaction?.comment?.isLoading ?? false; - const isLoading = transaction?.isLoading ?? false; - const hasRouteError = Boolean(transaction?.errorFields?.route); - const hasRoute = TransactionUtils.hasRoute((transaction ?? {}) as Transaction); - const validatedWaypoints = TransactionUtils.getValidWaypoints(waypoints); - const previousValidatedWaypoints = usePrevious(validatedWaypoints); - const haveValidatedWaypointsChanged = !lodashIsEqual(previousValidatedWaypoints, validatedWaypoints); - const isRouteAbsentWithoutErrors = !hasRoute && !hasRouteError; - const shouldFetchRoute = (isRouteAbsentWithoutErrors || haveValidatedWaypointsChanged) && !isLoadingRoute && Object.keys(validatedWaypoints).length > 1; - const transactionWasSaved = useRef(false); - - useEffect(() => { - MapboxToken.init(); - return MapboxToken.stop; - }, []); - - useEffect(() => { - if (!isEditingNewRequest && !isEditingRequest) { - return () => {}; - } - // This effect runs when the component is mounted and unmounted. It's purpose is to be able to properly - // discard changes if the user cancels out of making any changes. This is accomplished by backing up the - // original transaction, letting the user modify the current transaction, and then if the user ever - // cancels out of the modal without saving changes, the original transaction is restored from the backup. - - // On mount, create the backup transaction. - TransactionEdit.createBackupTransaction(transaction); - - return () => { - // If the user cancels out of the modal without without saving changes, then the original transaction - // needs to be restored from the backup so that all changes are removed. - if (transactionWasSaved.current) { - return; - } - TransactionEdit.restoreOriginalTransactionFromBackup(transaction?.transactionID ?? ''); - }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - - useEffect(() => { - const transactionWaypoints = transaction?.comment?.waypoints ?? {}; - if (!transaction?.transactionID || Object.keys(transactionWaypoints).length) { - return; - } - - // Create the initial start and stop waypoints - TransactionUserActions.createInitialWaypoints(transactionID); - return () => { - // Whenever we reset the transaction, we need to set errors as empty/false. - setHasError(false); - }; - }, [transaction, transactionID]); - - useEffect(() => { - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing - if (isOffline || !shouldFetchRoute) { - return; - } - - TransactionUserActions.getRoute(transactionID, validatedWaypoints); - }, [shouldFetchRoute, transactionID, validatedWaypoints, isOffline]); - - useEffect(() => { - if (numberOfWaypoints <= numberOfPreviousWaypoints) { - return; - } - scrollViewRef.current?.scrollToEnd({animated: true}); - }, [numberOfPreviousWaypoints, numberOfWaypoints]); - - useEffect(() => { - // Whenever we change waypoints we need to remove the error or it will keep showing the error. - if (lodashIsEqual(previousWaypoints, waypoints)) { - return; - } - setHasError(false); - }, [waypoints, previousWaypoints]); - - const navigateBack = () => { - Navigation.goBack(isEditingNewRequest ? ROUTES.MONEY_REQUEST_CONFIRMATION.getRoute(iouType, reportID) : ROUTES.HOME); - }; - - /** - * Takes the user to the page for editing a specific waypoint - */ - const navigateToWaypointEditPage = (index: number) => { - Navigation.navigate( - ROUTES.MONEY_REQUEST_STEP_WAYPOINT.getRoute( - CONST.IOU.ACTION.EDIT, - CONST.IOU.TYPE.REQUEST, - transactionID, - report?.reportID ?? '', - index.toString(), - Navigation.getActiveRouteWithoutParams(), - ), - ); - }; - - const getError = useCallback(() => { - // Get route error if available else show the invalid number of waypoints error. - if (hasRouteError) { - return ErrorUtils.getLatestErrorField((transaction ?? {}) as Transaction, 'route'); - } - - if (Object.keys(validatedWaypoints).length < 2) { - // eslint-disable-next-line @typescript-eslint/naming-convention - return {0: 'iou.error.atLeastTwoDifferentWaypoints'}; - } - - if (Object.keys(validatedWaypoints).length < Object.keys(waypoints).length) { - // eslint-disable-next-line @typescript-eslint/naming-convention - return {0: translate('iou.error.duplicateWaypointsErrorMessage')}; - } - }, [translate, transaction, hasRouteError, validatedWaypoints, waypoints]); - - const updateWaypoints = useCallback( - ({data}: DraggableListData) => { - if (lodashIsEqual(waypointsList, data)) { - return; - } - - const newWaypoints: WaypointCollection = {}; - let emptyWaypointIndex = -1; - data.forEach((waypoint, index) => { - newWaypoints[`waypoint${index}`] = waypoints?.[waypoint] ?? {}; - // Find waypoint that BECOMES empty after dragging - if (isEmptyObject(newWaypoints[`waypoint${index}`]) && !isEmptyObject(waypoints[`waypoint${index}`])) { - emptyWaypointIndex = index; - } - }); - - setOptimisticWaypoints(newWaypoints); - // eslint-disable-next-line rulesdir/no-thenable-actions-in-views - Promise.all([TransactionUserActions.removeWaypoint(transaction, emptyWaypointIndex.toString()), TransactionUserActions.updateWaypoints(transactionID, newWaypoints)]).then(() => { - setOptimisticWaypoints(undefined); - }); - }, - [transactionID, transaction, waypoints, waypointsList], - ); - - const submitWaypoints = useCallback(() => { - // If there is any error or loading state, don't let user go to next page. - if (!isEmptyObject(getError()) || isLoadingRoute || (isLoading && !isOffline)) { - setHasError(true); - return; - } - - if (isEditingNewRequest || isEditingRequest) { - transactionWasSaved.current = true; - } - - onSubmit(waypoints); - }, [onSubmit, setHasError, getError, isLoadingRoute, isLoading, waypoints, isEditingNewRequest, isEditingRequest, isOffline]); - - const content = ( - <> - - item} - shouldUsePortal - onDragEnd={updateWaypoints} - ref={scrollViewRef} - renderItem={({item, drag, isActive, getIndex}) => ( - number} - onPress={navigateToWaypointEditPage} - disabled={isLoadingRoute} - /> - )} - ListFooterComponent={ - - } - /> - - - {/* Show error message if there is route error or there are less than 2 routes and user has tried submitting, */} - {((hasError && !isEmptyObject(getError())) || hasRouteError) && ( - - )} -