diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts index 188ab5646d30..c4c93451498b 100755 --- a/src/ONYXKEYS.ts +++ b/src/ONYXKEYS.ts @@ -1,4 +1,4 @@ -import type {OnyxEntry} from 'react-native-onyx'; +import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; import type {ValueOf} from 'type-fest'; import type CONST from './CONST'; import type * as FormTypes from './types/form'; @@ -589,7 +589,7 @@ type OnyxFormDraftKey = keyof OnyxFormDraftValuesMapping; type OnyxValueKey = keyof OnyxValuesMapping; type OnyxKey = OnyxValueKey | OnyxCollectionKey | OnyxFormKey | OnyxFormDraftKey; -type OnyxValue = OnyxEntry; +type OnyxValue = TOnyxKey extends keyof OnyxCollectionValuesMapping ? OnyxCollection : OnyxEntry; type MissingOnyxKeysError = `Error: Types don't match, OnyxKey type is missing: ${Exclude}`; /** If this type errors, it means that the `OnyxKey` type is missing some keys. */ diff --git a/src/ROUTES.ts b/src/ROUTES.ts index bc8af4df5686..86a2615d0b25 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -554,6 +554,15 @@ const ROUTES = { route: 'workspace/:policyID/tags', getRoute: (policyID: string) => `workspace/${policyID}/tags` as const, }, + WORKSPACE_MEMBER_DETAILS: { + route: 'workspace/:policyID/members/:accountID', + getRoute: (policyID: string, accountID: number, backTo?: string) => getUrlWithBackToParam(`workspace/${policyID}/members/${accountID}`, backTo), + }, + WORKSPACE_MEMBER_ROLE_SELECTION: { + route: 'workspace/:policyID/members/:accountID/role-selection', + getRoute: (policyID: string, accountID: number, backTo?: string) => getUrlWithBackToParam(`workspace/${policyID}/members/${accountID}/role-selection`, backTo), + }, + // Referral program promotion REFERRAL_DETAILS_MODAL: { route: 'referral/:contentType', diff --git a/src/SCREENS.ts b/src/SCREENS.ts index 17073d289f8f..98eb13b6231a 100644 --- a/src/SCREENS.ts +++ b/src/SCREENS.ts @@ -226,6 +226,8 @@ const SCREENS = { CATEGORY_CREATE: 'Category_Create', CATEGORY_SETTINGS: 'Category_Settings', CATEGORIES_SETTINGS: 'Categories_Settings', + MEMBER_DETAILS: 'Workspace_Member_Details', + MEMBER_DETAILS_ROLE_SELECTION: 'Workspace_Member_Details_Role_Selection', }, EDIT_REQUEST: { diff --git a/src/components/Button/index.tsx b/src/components/Button/index.tsx index a25c7ff7129c..ca1bc391e800 100644 --- a/src/components/Button/index.tsx +++ b/src/components/Button/index.tsx @@ -198,7 +198,7 @@ function Button( accessibilityLabel = '', ...rest }: ButtonProps, - ref: ForwardedRef, + ref: ForwardedRef, ) { const theme = useTheme(); const styles = useThemeStyles(); diff --git a/src/components/FloatingActionButton.tsx b/src/components/FloatingActionButton.tsx index 9b68916c4003..88938f31cd79 100644 --- a/src/components/FloatingActionButton.tsx +++ b/src/components/FloatingActionButton.tsx @@ -1,6 +1,7 @@ import type {ForwardedRef} from 'react'; import React, {forwardRef, useEffect, useRef} from 'react'; -import type {GestureResponderEvent, Role} from 'react-native'; +// eslint-disable-next-line no-restricted-imports +import type {GestureResponderEvent, Role, Text} from 'react-native'; import {Platform, View} from 'react-native'; import Animated, {createAnimatedPropAdapter, Easing, interpolateColor, processColor, useAnimatedProps, useAnimatedStyle, useSharedValue, withTiming} from 'react-native-reanimated'; import Svg, {Path} from 'react-native-svg'; @@ -58,12 +59,12 @@ type FloatingActionButtonProps = { role: Role; }; -function FloatingActionButton({onPress, isActive, accessibilityLabel, role}: FloatingActionButtonProps, ref: ForwardedRef) { +function FloatingActionButton({onPress, isActive, accessibilityLabel, role}: FloatingActionButtonProps, ref: ForwardedRef) { const {success, buttonDefaultBG, textLight, textDark} = useTheme(); const styles = useThemeStyles(); const borderRadius = styles.floatingActionButton.borderRadius; const {translate} = useLocalize(); - const fabPressable = useRef(null); + const fabPressable = useRef(null); const sharedValue = useSharedValue(isActive ? 1 : 0); const buttonRef = ref; @@ -112,9 +113,9 @@ function FloatingActionButton({onPress, isActive, accessibilityLabel, role}: Flo { - fabPressable.current = el; + fabPressable.current = el ?? null; if (buttonRef && 'current' in buttonRef) { - buttonRef.current = el; + buttonRef.current = el ?? null; } }} accessibilityLabel={accessibilityLabel} diff --git a/src/components/KYCWall/BaseKYCWall.tsx b/src/components/KYCWall/BaseKYCWall.tsx index 33dfd2b920a9..7d2f99f49593 100644 --- a/src/components/KYCWall/BaseKYCWall.tsx +++ b/src/components/KYCWall/BaseKYCWall.tsx @@ -1,6 +1,6 @@ import React, {useCallback, useEffect, useRef, useState} from 'react'; import {Dimensions} from 'react-native'; -import type {EmitterSubscription, GestureResponderEvent} from 'react-native'; +import type {EmitterSubscription, GestureResponderEvent, View} from 'react-native'; import {withOnyx} from 'react-native-onyx'; import type {OnyxEntry} from 'react-native-onyx'; import AddPaymentMethodMenu from '@components/AddPaymentMethodMenu'; @@ -69,8 +69,8 @@ function KYCWall({ walletTerms, shouldShowPersonalBankAccountOption = false, }: BaseKYCWallProps) { - const anchorRef = useRef(null); - const transferBalanceButtonRef = useRef(null); + const anchorRef = useRef(null); + const transferBalanceButtonRef = useRef(null); const [shouldShowAddPaymentMenu, setShouldShowAddPaymentMenu] = useState(false); @@ -111,7 +111,7 @@ function KYCWall({ return; } - const buttonPosition = getClickedTargetLocation(transferBalanceButtonRef.current); + const buttonPosition = getClickedTargetLocation(transferBalanceButtonRef.current as HTMLDivElement); const position = getAnchorPosition(buttonPosition); setPositionAddPaymentMenu(position); @@ -162,7 +162,7 @@ function KYCWall({ } // Use event target as fallback if anchorRef is null for safety - const targetElement = anchorRef.current ?? (event?.currentTarget as HTMLElement); + const targetElement = anchorRef.current ?? (event?.currentTarget as HTMLDivElement); transferBalanceButtonRef.current = targetElement; @@ -181,7 +181,7 @@ function KYCWall({ return; } - const clickedElementLocation = getClickedTargetLocation(targetElement); + const clickedElementLocation = getClickedTargetLocation(targetElement as HTMLDivElement); const position = getAnchorPosition(clickedElementLocation); setPositionAddPaymentMenu(position); diff --git a/src/components/OnyxProvider.tsx b/src/components/OnyxProvider.tsx index d14aec90fa10..0bc9130ea4a8 100644 --- a/src/components/OnyxProvider.tsx +++ b/src/components/OnyxProvider.tsx @@ -8,8 +8,8 @@ import createOnyxContext from './createOnyxContext'; const [withNetwork, NetworkProvider, NetworkContext] = createOnyxContext(ONYXKEYS.NETWORK); const [withPersonalDetails, PersonalDetailsProvider, , usePersonalDetails] = createOnyxContext(ONYXKEYS.PERSONAL_DETAILS_LIST); const [withCurrentDate, CurrentDateProvider] = createOnyxContext(ONYXKEYS.CURRENT_DATE); -const [withReportActionsDrafts, ReportActionsDraftsProvider] = createOnyxContext(ONYXKEYS.COLLECTION.REPORT_ACTIONS_DRAFTS); -const [withBlockedFromConcierge, BlockedFromConciergeProvider] = createOnyxContext(ONYXKEYS.NVP_BLOCKED_FROM_CONCIERGE); +const [withReportActionsDrafts, ReportActionsDraftsProvider, , useReportActionsDrafts] = createOnyxContext(ONYXKEYS.COLLECTION.REPORT_ACTIONS_DRAFTS); +const [withBlockedFromConcierge, BlockedFromConciergeProvider, , useBlockedFromConcierge] = createOnyxContext(ONYXKEYS.NVP_BLOCKED_FROM_CONCIERGE); const [withBetas, BetasProvider, BetasContext, useBetas] = createOnyxContext(ONYXKEYS.BETAS); const [withReportCommentDrafts, ReportCommentDraftsProvider] = createOnyxContext(ONYXKEYS.COLLECTION.REPORT_DRAFT_COMMENT); const [withPreferredTheme, PreferredThemeProvider, PreferredThemeContext] = createOnyxContext(ONYXKEYS.PREFERRED_THEME); @@ -66,5 +66,7 @@ export { useFrequentlyUsedEmojis, withPreferredEmojiSkinTone, PreferredEmojiSkinToneContext, + useBlockedFromConcierge, + useReportActionsDrafts, useSession, }; diff --git a/src/components/Popover/types.ts b/src/components/Popover/types.ts index e06037f47b63..314c1ba141c3 100644 --- a/src/components/Popover/types.ts +++ b/src/components/Popover/types.ts @@ -1,5 +1,6 @@ import type {RefObject} from 'react'; -import type {View} from 'react-native'; +// eslint-disable-next-line no-restricted-imports +import type {Text, View} from 'react-native'; import type {PopoverAnchorPosition} from '@components/Modal/types'; import type BaseModalProps from '@components/Modal/types'; import type {WindowDimensionsProps} from '@components/withWindowDimensions/types'; @@ -20,7 +21,7 @@ type PopoverProps = BaseModalProps & anchorAlignment?: AnchorAlignment; /** The anchor ref of the popover */ - anchorRef: RefObject; + anchorRef: RefObject; /** Whether disable the animations */ disableAnimation?: boolean; diff --git a/src/components/PopoverProvider/index.tsx b/src/components/PopoverProvider/index.tsx index 67481b41d50b..cc6c84477525 100644 --- a/src/components/PopoverProvider/index.tsx +++ b/src/components/PopoverProvider/index.tsx @@ -1,6 +1,7 @@ import type {RefObject} from 'react'; import React, {createContext, useCallback, useEffect, useMemo, useRef, useState} from 'react'; -import type {View} from 'react-native'; +// eslint-disable-next-line no-restricted-imports +import type {Text, View} from 'react-native'; import type {AnchorRef, PopoverContextProps, PopoverContextValue} from './types'; const PopoverContext = createContext({ @@ -10,7 +11,7 @@ const PopoverContext = createContext({ isOpen: false, }); -function elementContains(ref: RefObject | undefined, target: EventTarget | null) { +function elementContains(ref: RefObject | undefined, target: EventTarget | null) { if (ref?.current && 'contains' in ref.current && ref?.current?.contains(target as Node)) { return true; } @@ -21,7 +22,7 @@ function PopoverContextProvider(props: PopoverContextProps) { const [isOpen, setIsOpen] = useState(false); const activePopoverRef = useRef(null); - const closePopover = useCallback((anchorRef?: RefObject): boolean => { + const closePopover = useCallback((anchorRef?: RefObject): boolean => { if (!activePopoverRef.current || (anchorRef && anchorRef !== activePopoverRef.current.anchorRef)) { return false; } diff --git a/src/components/PopoverProvider/types.ts b/src/components/PopoverProvider/types.ts index 2a366ae2a712..5022aee0f843 100644 --- a/src/components/PopoverProvider/types.ts +++ b/src/components/PopoverProvider/types.ts @@ -1,5 +1,6 @@ import type {ReactNode, RefObject} from 'react'; -import type {View} from 'react-native'; +// eslint-disable-next-line no-restricted-imports +import type {Text, View} from 'react-native'; type PopoverContextProps = { children: ReactNode; @@ -8,14 +9,14 @@ type PopoverContextProps = { type PopoverContextValue = { onOpen?: (popoverParams: AnchorRef) => void; popover?: AnchorRef | Record | null; - close: (anchorRef?: RefObject) => void; + close: (anchorRef?: RefObject) => void; isOpen: boolean; }; type AnchorRef = { - ref: RefObject; - close: (anchorRef?: RefObject) => void; - anchorRef: RefObject; + ref: RefObject; + close: (anchorRef?: RefObject) => void; + anchorRef: RefObject; }; export type {PopoverContextProps, PopoverContextValue, AnchorRef}; diff --git a/src/components/PopoverWithoutOverlay/types.ts b/src/components/PopoverWithoutOverlay/types.ts index 0d24cdd4bd9f..8fe40119ca61 100644 --- a/src/components/PopoverWithoutOverlay/types.ts +++ b/src/components/PopoverWithoutOverlay/types.ts @@ -1,5 +1,6 @@ import type {RefObject} from 'react'; -import type {View} from 'react-native'; +// eslint-disable-next-line no-restricted-imports +import type {Text, View} from 'react-native'; import type BaseModalProps from '@components/Modal/types'; import type ChildrenProps from '@src/types/utils/ChildrenProps'; @@ -14,7 +15,7 @@ type PopoverWithoutOverlayProps = ChildrenProps & }; /** The anchor ref of the popover */ - anchorRef: RefObject; + anchorRef: RefObject; /** A react-native-animatable animation timing for the modal display animation */ animationInTiming?: number; diff --git a/src/components/Pressable/GenericPressable/types.ts b/src/components/Pressable/GenericPressable/types.ts index 2dd2e17e0454..9040a844e5a7 100644 --- a/src/components/Pressable/GenericPressable/types.ts +++ b/src/components/Pressable/GenericPressable/types.ts @@ -1,5 +1,6 @@ import type {ElementRef, ForwardedRef, RefObject} from 'react'; -import type {GestureResponderEvent, HostComponent, PressableStateCallbackType, PressableProps as RNPressableProps, StyleProp, View, ViewStyle} from 'react-native'; +// eslint-disable-next-line no-restricted-imports +import type {GestureResponderEvent, HostComponent, PressableStateCallbackType, PressableProps as RNPressableProps, Text as RNText, StyleProp, View, ViewStyle} from 'react-native'; import type {ValueOf} from 'type-fest'; import type {Shortcut} from '@libs/KeyboardShortcut'; import type CONST from '@src/CONST'; @@ -138,7 +139,7 @@ type PressableProps = RNPressableProps & noDragArea?: boolean; }; -type PressableRef = ForwardedRef; +type PressableRef = ForwardedRef; export default PressableProps; export type {PressableRef}; diff --git a/src/components/PressableWithSecondaryInteraction/types.ts b/src/components/PressableWithSecondaryInteraction/types.ts index aa67d45d66fb..b07c867daeb3 100644 --- a/src/components/PressableWithSecondaryInteraction/types.ts +++ b/src/components/PressableWithSecondaryInteraction/types.ts @@ -4,7 +4,7 @@ import type {ParsableStyle} from '@styles/utils/types'; type PressableWithSecondaryInteractionProps = PressableWithFeedbackProps & { /** The function that should be called when this pressable is pressed */ - onPress: (event?: GestureResponderEvent) => void; + onPress?: (event?: GestureResponderEvent) => void; /** The function that should be called when this pressable is pressedIn */ onPressIn?: (event?: GestureResponderEvent) => void; diff --git a/src/components/Reactions/ReportActionItemEmojiReactions.tsx b/src/components/Reactions/ReportActionItemEmojiReactions.tsx index 7e95ab670b7e..c6bf4f9e4016 100644 --- a/src/components/Reactions/ReportActionItemEmojiReactions.tsx +++ b/src/components/Reactions/ReportActionItemEmojiReactions.tsx @@ -23,7 +23,7 @@ type ReportActionItemEmojiReactionsProps = WithCurrentUserPersonalDetailsProps & emojiReactions: OnyxEntry; /** The user's preferred locale. */ - preferredLocale: OnyxEntry; + preferredLocale?: OnyxEntry; /** The report action that these reactions are for */ reportAction: ReportAction; @@ -155,7 +155,7 @@ function ReportActionItemEmojiReactions({ shouldDisableOpacity={!!reportAction.pendingAction} > (popoverReactionListAnchors.current[reaction.reactionEmojiName] = ref)} + ref={(ref) => (popoverReactionListAnchors.current[reaction.reactionEmojiName] = ref ?? null)} count={reaction.reactionCount} emojiCodes={reaction.emojiCodes} onPress={reaction.onPress} diff --git a/src/components/ReportActionItem/MoneyReportView.tsx b/src/components/ReportActionItem/MoneyReportView.tsx index f0cd8dc1b4b5..1a740d51a2af 100644 --- a/src/components/ReportActionItem/MoneyReportView.tsx +++ b/src/components/ReportActionItem/MoneyReportView.tsx @@ -2,6 +2,7 @@ import Str from 'expensify-common/lib/str'; import React, {useMemo} from 'react'; import type {StyleProp, TextStyle} from 'react-native'; import {View} from 'react-native'; +import type {OnyxEntry} from 'react-native-onyx'; import Icon from '@components/Icon'; import * as Expensicons from '@components/Icon/Expensicons'; import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; @@ -26,7 +27,7 @@ type MoneyReportViewProps = { report: Report; /** Policy that the report belongs to */ - policy: Policy; + policy: OnyxEntry; /** Policy report fields */ policyReportFields: PolicyReportField[]; diff --git a/src/components/ReportActionItem/TaskAction.tsx b/src/components/ReportActionItem/TaskAction.tsx index b10be4e86fe8..7e9262bb4c05 100644 --- a/src/components/ReportActionItem/TaskAction.tsx +++ b/src/components/ReportActionItem/TaskAction.tsx @@ -1,20 +1,24 @@ import React from 'react'; import {View} from 'react-native'; +import type {OnyxEntry} from 'react-native-onyx'; +import RenderHTML from '@components/RenderHTML'; import Text from '@components/Text'; import useThemeStyles from '@hooks/useThemeStyles'; import * as TaskUtils from '@libs/TaskUtils'; +import type {ReportAction} from '@src/types/onyx'; type TaskActionProps = { /** Name of the reportAction action */ - actionName: string; + action: OnyxEntry; }; -function TaskAction({actionName}: TaskActionProps) { +function TaskAction({action}: TaskActionProps) { const styles = useThemeStyles(); + const message = TaskUtils.getTaskReportActionMessage(action); return ( - {TaskUtils.getTaskReportActionMessage(actionName)} + {message.html ? ${message.html}`} /> : {message.text}} ); } diff --git a/src/components/createOnyxContext.tsx b/src/components/createOnyxContext.tsx index 50cda00b17b4..c19b8006c86c 100644 --- a/src/components/createOnyxContext.tsx +++ b/src/components/createOnyxContext.tsx @@ -3,7 +3,7 @@ import type {ComponentType, ForwardedRef, ForwardRefExoticComponent, PropsWithou import React, {createContext, forwardRef, useContext} from 'react'; import {withOnyx} from 'react-native-onyx'; import getComponentDisplayName from '@libs/getComponentDisplayName'; -import type {OnyxKey, OnyxValue, OnyxValues} from '@src/ONYXKEYS'; +import type {OnyxKey, OnyxValue} from '@src/ONYXKEYS'; import type ChildrenProps from '@src/types/utils/ChildrenProps'; // Provider types @@ -32,11 +32,11 @@ type CreateOnyxContext = [ WithOnyxKey, ComponentType, TOnyxKey>>, React.Context>, - () => OnyxValues[TOnyxKey], + () => NonNullable>, ]; export default (onyxKeyName: TOnyxKey): CreateOnyxContext => { - const Context = createContext>(null); + const Context = createContext>(null as OnyxValue); function Provider(props: ProviderPropsWithOnyx): ReactNode { return {props.children}; } @@ -86,7 +86,7 @@ export default (onyxKeyName: TOnyxKey): CreateOnyxCont if (value === null) { throw new Error(`useOnyxContext must be used within a OnyxProvider [key: ${onyxKeyName}]`); } - return value; + return value as NonNullable>; }; return [withOnyxKey, ProviderWithOnyx, Context, useOnyxContext]; diff --git a/src/languages/en.ts b/src/languages/en.ts index 1ba613610723..b5166fb71a91 100755 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -1809,6 +1809,9 @@ export default { genericFailureMessage: 'An error occurred removing a user from the workspace, please try again.', removeMembersPrompt: 'Are you sure you want to remove these members?', removeMembersTitle: 'Remove members', + removeMemberButtonTitle: 'Remove from workspace', + removeMemberPrompt: ({memberName}) => `Are you sure you want to remove ${memberName}`, + removeMemberTitle: 'Remove member', makeMember: 'Make member', makeAdmin: 'Make admin', selectAll: 'Select all', diff --git a/src/languages/es.ts b/src/languages/es.ts index acbb70308167..2b2b379040bc 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -1833,6 +1833,9 @@ export default { genericFailureMessage: 'Se ha producido un error al intentar eliminar a un usuario del espacio de trabajo. Por favor, inténtalo más tarde.', removeMembersPrompt: '¿Estás seguro de que deseas eliminar a estos miembros?', removeMembersTitle: 'Eliminar miembros', + removeMemberButtonTitle: 'Quitar del espacio de trabajo', + removeMemberPrompt: ({memberName}) => `¿Estás seguro de que deseas eliminar a ${memberName}`, + removeMemberTitle: 'Eliminar miembro', makeMember: 'Hacer miembro', makeAdmin: 'Hacer administrador', selectAll: 'Seleccionar todo', diff --git a/src/libs/EmojiUtils.ts b/src/libs/EmojiUtils.ts index cab0f48d75fd..33cda171f24b 100644 --- a/src/libs/EmojiUtils.ts +++ b/src/libs/EmojiUtils.ts @@ -242,9 +242,13 @@ function getFrequentlyUsedEmojis(newEmoji: Emoji | Emoji[]): FrequentlyUsedEmoji /** * Given an emoji item object, return an emoji code based on its type. */ -const getEmojiCodeWithSkinColor = (item: Emoji, preferredSkinToneIndex: number): string => { +const getEmojiCodeWithSkinColor = (item: Emoji, preferredSkinToneIndex: OnyxEntry): string | undefined => { const {code, types} = item; - if (types?.[preferredSkinToneIndex]) { + if (!preferredSkinToneIndex) { + return; + } + + if (typeof preferredSkinToneIndex === 'number' && types?.[preferredSkinToneIndex]) { return types[preferredSkinToneIndex]; } @@ -305,7 +309,7 @@ function getAddedEmojis(currentEmojis: Emoji[], formerEmojis: Emoji[]): Emoji[] * Replace any emoji name in a text with the emoji icon. * If we're on mobile, we also add a space after the emoji granted there's no text after it. */ -function replaceEmojis(text: string, preferredSkinTone: number = CONST.EMOJI_DEFAULT_SKIN_TONE, lang: Locale = CONST.LOCALES.DEFAULT): ReplacedEmoji { +function replaceEmojis(text: string, preferredSkinTone: OnyxEntry = CONST.EMOJI_DEFAULT_SKIN_TONE, lang: Locale = CONST.LOCALES.DEFAULT): ReplacedEmoji { // emojisTrie is importing the emoji JSON file on the app starting and we want to avoid it const emojisTrie = require('./EmojiTrie').default; @@ -345,9 +349,9 @@ function replaceEmojis(text: string, preferredSkinTone: number = CONST.EMOJI_DEF // Set the cursor to the end of the last replaced Emoji. Note that we position after // the extra space, if we added one. - cursorPosition = newText.indexOf(emoji) + emojiReplacement.length; + cursorPosition = newText.indexOf(emoji) + (emojiReplacement?.length ?? 0); - newText = newText.replace(emoji, emojiReplacement); + newText = newText.replace(emoji, emojiReplacement ?? ''); } } @@ -369,7 +373,7 @@ function replaceEmojis(text: string, preferredSkinTone: number = CONST.EMOJI_DEF /** * Find all emojis in a text and replace them with their code. */ -function replaceAndExtractEmojis(text: string, preferredSkinTone: number = CONST.EMOJI_DEFAULT_SKIN_TONE, lang: Locale = CONST.LOCALES.DEFAULT): ReplacedEmoji { +function replaceAndExtractEmojis(text: string, preferredSkinTone: OnyxEntry = CONST.EMOJI_DEFAULT_SKIN_TONE, lang: Locale = CONST.LOCALES.DEFAULT): ReplacedEmoji { const {text: convertedText = '', emojis = [], cursorPosition} = replaceEmojis(text, preferredSkinTone, lang); return { diff --git a/src/libs/ErrorUtils.ts b/src/libs/ErrorUtils.ts index 20313ee8912d..784d339a4a0d 100644 --- a/src/libs/ErrorUtils.ts +++ b/src/libs/ErrorUtils.ts @@ -180,3 +180,5 @@ export { getMicroSecondOnyxErrorObject, isReceiptError, }; + +export type {OnyxDataWithErrors}; diff --git a/src/libs/Navigation/AppNavigator/ModalStackNavigators.tsx b/src/libs/Navigation/AppNavigator/ModalStackNavigators.tsx index cee9e31cedea..978e338796ea 100644 --- a/src/libs/Navigation/AppNavigator/ModalStackNavigators.tsx +++ b/src/libs/Navigation/AppNavigator/ModalStackNavigators.tsx @@ -251,6 +251,8 @@ const SettingsModalStackNavigator = createModalStackNavigator require('../../../pages/workspace/WorkspaceProfileCurrencyPage').default as React.ComponentType, [SCREENS.WORKSPACE.CATEGORY_SETTINGS]: () => require('../../../pages/workspace/categories/CategorySettingsPage').default as React.ComponentType, [SCREENS.WORKSPACE.CATEGORIES_SETTINGS]: () => require('../../../pages/workspace/categories/WorkspaceCategoriesSettingsPage').default as React.ComponentType, + [SCREENS.WORKSPACE.MEMBER_DETAILS]: () => require('../../../pages/workspace/members/WorkspaceMemberDetailsPage').default as React.ComponentType, + [SCREENS.WORKSPACE.MEMBER_DETAILS_ROLE_SELECTION]: () => require('../../../pages/workspace/members/WorkspaceMemberDetailsRoleSelectionPage').default as React.ComponentType, [SCREENS.WORKSPACE.CATEGORY_CREATE]: () => require('../../../pages/workspace/categories/CreateCategoryPage').default as React.ComponentType, [SCREENS.REIMBURSEMENT_ACCOUNT]: () => require('../../../pages/ReimbursementAccount/ReimbursementAccountPage').default as React.ComponentType, [SCREENS.GET_ASSISTANCE]: () => require('../../../pages/GetAssistancePage').default as React.ComponentType, diff --git a/src/libs/Navigation/AppNavigator/createCustomBottomTabNavigator/BottomTabBar.tsx b/src/libs/Navigation/AppNavigator/createCustomBottomTabNavigator/BottomTabBar.tsx index f38ec213a466..58d9efb43df5 100644 --- a/src/libs/Navigation/AppNavigator/createCustomBottomTabNavigator/BottomTabBar.tsx +++ b/src/libs/Navigation/AppNavigator/createCustomBottomTabNavigator/BottomTabBar.tsx @@ -11,6 +11,7 @@ import useActiveWorkspace from '@hooks/useActiveWorkspace'; import useLocalize from '@hooks/useLocalize'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; +import * as Session from '@libs/actions/Session'; import interceptAnonymousUser from '@libs/interceptAnonymousUser'; import getTopmostBottomTabRoute from '@libs/Navigation/getTopmostBottomTabRoute'; import Navigation from '@libs/Navigation/Navigation'; @@ -47,7 +48,8 @@ function BottomTabBar({isLoadingApp = false}: PurposeForUsingExpensifyModalProps // When we are redirected to the Settings tab from the OldDot, we don't want to call the Welcome.show() method. // To prevent this, the value of the bottomTabRoute?.name is checked here bottomTabRoute?.name === SCREENS.WORKSPACE.INITIAL || - (currentRoute && currentRoute.name !== NAVIGATORS.BOTTOM_TAB_NAVIGATOR && currentRoute.name !== NAVIGATORS.CENTRAL_PANE_NAVIGATOR) + Boolean(currentRoute && currentRoute.name !== NAVIGATORS.BOTTOM_TAB_NAVIGATOR && currentRoute.name !== NAVIGATORS.CENTRAL_PANE_NAVIGATOR) || + Session.isAnonymousUser() ) { return; } diff --git a/src/libs/Navigation/linkingConfig/CENTRAL_PANE_TO_RHP_MAPPING.ts b/src/libs/Navigation/linkingConfig/CENTRAL_PANE_TO_RHP_MAPPING.ts index 7cca4ed466d4..5bc7d52230a8 100755 --- a/src/libs/Navigation/linkingConfig/CENTRAL_PANE_TO_RHP_MAPPING.ts +++ b/src/libs/Navigation/linkingConfig/CENTRAL_PANE_TO_RHP_MAPPING.ts @@ -4,7 +4,7 @@ import SCREENS from '@src/SCREENS'; const CENTRAL_PANE_TO_RHP_MAPPING: Partial> = { [SCREENS.WORKSPACE.PROFILE]: [SCREENS.WORKSPACE.NAME, SCREENS.WORKSPACE.CURRENCY, SCREENS.WORKSPACE.DESCRIPTION, SCREENS.WORKSPACE.SHARE], [SCREENS.WORKSPACE.REIMBURSE]: [SCREENS.WORKSPACE.RATE_AND_UNIT, SCREENS.WORKSPACE.RATE_AND_UNIT_RATE, SCREENS.WORKSPACE.RATE_AND_UNIT_UNIT], - [SCREENS.WORKSPACE.MEMBERS]: [SCREENS.WORKSPACE.INVITE, SCREENS.WORKSPACE.INVITE_MESSAGE], + [SCREENS.WORKSPACE.MEMBERS]: [SCREENS.WORKSPACE.INVITE, SCREENS.WORKSPACE.INVITE_MESSAGE, SCREENS.WORKSPACE.MEMBER_DETAILS, SCREENS.WORKSPACE.MEMBER_DETAILS_ROLE_SELECTION], [SCREENS.WORKSPACE.WORKFLOWS]: [SCREENS.WORKSPACE.WORKFLOWS_APPROVER, SCREENS.WORKSPACE.WORKFLOWS_AUTO_REPORTING_FREQUENCY, SCREENS.WORKSPACE.WORKFLOWS_AUTO_REPORTING_MONTHLY_OFFSET], [SCREENS.WORKSPACE.CATEGORIES]: [SCREENS.WORKSPACE.CATEGORY_CREATE, SCREENS.WORKSPACE.CATEGORY_SETTINGS, SCREENS.WORKSPACE.CATEGORIES_SETTINGS], }; diff --git a/src/libs/Navigation/linkingConfig/config.ts b/src/libs/Navigation/linkingConfig/config.ts index d3eb6d6a4c2b..b16f751a5efe 100644 --- a/src/libs/Navigation/linkingConfig/config.ts +++ b/src/libs/Navigation/linkingConfig/config.ts @@ -280,6 +280,12 @@ const config: LinkingOptions['config'] = { [SCREENS.WORKSPACE.CATEGORIES_SETTINGS]: { path: ROUTES.WORKSPACE_CATEGORIES_SETTINGS.route, }, + [SCREENS.WORKSPACE.MEMBER_DETAILS]: { + path: ROUTES.WORKSPACE_MEMBER_DETAILS.route, + }, + [SCREENS.WORKSPACE.MEMBER_DETAILS_ROLE_SELECTION]: { + path: ROUTES.WORKSPACE_MEMBER_ROLE_SELECTION.route, + }, [SCREENS.WORKSPACE.CATEGORY_CREATE]: { path: ROUTES.WORKSPACE_CATEGORY_CREATE.route, }, diff --git a/src/libs/Navigation/types.ts b/src/libs/Navigation/types.ts index 12b2802cb3fa..ad4c124ca51b 100644 --- a/src/libs/Navigation/types.ts +++ b/src/libs/Navigation/types.ts @@ -208,6 +208,16 @@ type SettingsNavigatorParamList = { [SCREENS.WORKSPACE.CATEGORIES_SETTINGS]: { policyID: string; }; + [SCREENS.WORKSPACE.MEMBER_DETAILS]: { + policyID: string; + accountID: string; + backTo: Routes; + }; + [SCREENS.WORKSPACE.MEMBER_DETAILS_ROLE_SELECTION]: { + policyID: string; + accountID: string; + backTo: Routes; + }; [SCREENS.GET_ASSISTANCE]: { backTo: Routes; }; diff --git a/src/libs/OnyxSelectors/reportWithoutHasDraftSelector.ts b/src/libs/OnyxSelectors/reportWithoutHasDraftSelector.ts index 9c7e6402d69b..82410b120df2 100644 --- a/src/libs/OnyxSelectors/reportWithoutHasDraftSelector.ts +++ b/src/libs/OnyxSelectors/reportWithoutHasDraftSelector.ts @@ -1,6 +1,7 @@ -import type {OnyxValue} from '@src/ONYXKEYS'; +import type {OnyxEntry} from 'react-native-onyx'; +import type {Report} from '@src/types/onyx'; -export default function reportWithoutHasDraftSelector(report: OnyxValue<'report_'>) { +export default function reportWithoutHasDraftSelector(report: OnyxEntry) { if (!report) { return report; } diff --git a/src/libs/OptionsListUtils.ts b/src/libs/OptionsListUtils.ts index 07f0df962455..fd803a508b4a 100644 --- a/src/libs/OptionsListUtils.ts +++ b/src/libs/OptionsListUtils.ts @@ -107,7 +107,7 @@ type Hierarchy = Record; selectedOptions?: Option[]; maxRecentReportsToShow?: number; excludeLogins?: string[]; @@ -156,7 +156,6 @@ type SectionForSearchTerm = { section: CategorySection; newIndexOffset: number; }; - type GetOptions = { recentReports: ReportUtils.OptionData[]; personalDetails: ReportUtils.OptionData[]; @@ -533,7 +532,6 @@ function getLastMessageTextForReport(report: OnyxEntry, lastActorDetails // some types of actions are filtered out for lastReportAction, in some cases we need to check the actual last action const lastOriginalReportAction = lastReportActions[report?.reportID ?? ''] ?? null; let lastMessageTextFromReport = ''; - const lastActionName = lastReportAction?.actionName ?? ''; if (ReportUtils.isArchivedRoom(report)) { const archiveReason = @@ -585,12 +583,8 @@ function getLastMessageTextForReport(report: OnyxEntry, lastActorDetails } else if (ReportActionUtils.isModifiedExpenseAction(lastReportAction)) { const properSchemaForModifiedExpenseMessage = ModifiedExpenseMessage.getForReportAction(report?.reportID, lastReportAction); lastMessageTextFromReport = ReportUtils.formatReportLastMessageText(properSchemaForModifiedExpenseMessage, true); - } else if ( - lastActionName === CONST.REPORT.ACTIONS.TYPE.TASKCOMPLETED || - lastActionName === CONST.REPORT.ACTIONS.TYPE.TASKREOPENED || - lastActionName === CONST.REPORT.ACTIONS.TYPE.TASKCANCELLED - ) { - lastMessageTextFromReport = lastReportAction?.message?.[0].text ?? ''; + } else if (ReportActionUtils.isTaskAction(lastReportAction)) { + lastMessageTextFromReport = TaskUtils.getTaskReportActionMessage(lastReportAction).text; } else if (ReportActionUtils.isCreatedTaskReportAction(lastReportAction)) { lastMessageTextFromReport = TaskUtils.getTaskCreatedMessage(lastReportAction); } else if (ReportActionUtils.isApprovedOrSubmittedReportAction(lastReportAction)) { @@ -1441,7 +1435,8 @@ function getOptions( const {parentReportID, parentReportActionID} = report ?? {}; const canGetParentReport = parentReportID && parentReportActionID && allReportActions; const parentReportAction = canGetParentReport ? allReportActions[parentReportID]?.[parentReportActionID] ?? null : null; - const doesReportHaveViolations = betas.includes(CONST.BETAS.VIOLATIONS) && ReportUtils.doesTransactionThreadHaveViolations(report, transactionViolations, parentReportAction); + const doesReportHaveViolations = + (betas?.includes(CONST.BETAS.VIOLATIONS) && ReportUtils.doesTransactionThreadHaveViolations(report, transactionViolations, parentReportAction)) ?? false; return ReportUtils.shouldReportBeInOptionList({ report, @@ -1805,7 +1800,7 @@ function getIOUConfirmationOptionsFromParticipants(participants: Participant[], function getFilteredOptions( reports: OnyxCollection, personalDetails: OnyxEntry, - betas: Beta[] = [], + betas: OnyxEntry = [], searchValue = '', selectedOptions: Array> = [], excludeLogins: string[] = [], @@ -1852,9 +1847,9 @@ function getFilteredOptions( */ function getShareDestinationOptions( - reports: Record, + reports: Record, personalDetails: OnyxEntry, - betas: Beta[] = [], + betas: OnyxEntry = [], searchValue = '', selectedOptions: Array> = [], excludeLogins: string[] = [], diff --git a/src/libs/ReportActionsUtils.ts b/src/libs/ReportActionsUtils.ts index 0b6c9671d69c..caa9ca226f05 100644 --- a/src/libs/ReportActionsUtils.ts +++ b/src/libs/ReportActionsUtils.ts @@ -382,10 +382,6 @@ function shouldReportActionBeVisible(reportAction: OnyxEntry, key: return false; } - if (reportAction.actionName === CONST.REPORT.ACTIONS.TYPE.TASKEDITED) { - return false; - } - // Filter out any unsupported reportAction types if (!supportedActionTypes.includes(reportAction.actionName)) { return false; @@ -675,7 +671,8 @@ function isTaskAction(reportAction: OnyxEntry): boolean { return ( reportActionName === CONST.REPORT.ACTIONS.TYPE.TASKCOMPLETED || reportActionName === CONST.REPORT.ACTIONS.TYPE.TASKCANCELLED || - reportActionName === CONST.REPORT.ACTIONS.TYPE.TASKREOPENED + reportActionName === CONST.REPORT.ACTIONS.TYPE.TASKREOPENED || + reportActionName === CONST.REPORT.ACTIONS.TYPE.TASKEDITED ); } @@ -832,7 +829,7 @@ function hasRequestFromCurrentAccount(reportID: string, currentAccountID: number * Checks if a given report action corresponds to an actionable mention whisper. * @param reportAction */ -function isActionableMentionWhisper(reportAction: OnyxEntry): boolean { +function isActionableMentionWhisper(reportAction: OnyxEntry): reportAction is ReportActionBase & OriginalMessageActionableMentionWhisper { return reportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.ACTIONABLEMENTIONWHISPER; } diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index ad2e894089cf..5dad57036df0 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -28,6 +28,7 @@ import type { ReportAction, ReportMetadata, Session, + Task, Transaction, TransactionViolation, } from '@src/types/onyx'; @@ -515,6 +516,14 @@ Onyx.connect({ }, }); +function getCurrentUserAvatarOrDefault(): UserUtils.AvatarSource { + return currentUserPersonalDetails?.avatar ?? UserUtils.getDefaultAvatarURL(currentUserAccountID); +} + +function getCurrentUserDisplayNameOrEmail(): string | undefined { + return currentUserPersonalDetails?.displayName ?? currentUserEmail; +} + function getChatType(report: OnyxEntry | Participant | EmptyObject): ValueOf | undefined { return report?.chatType; } @@ -1834,7 +1843,7 @@ function buildOptimisticCancelPaymentReportAction(expenseReportID: string, amoun person: [ { style: 'strong', - text: currentUserPersonalDetails?.displayName ?? currentUserEmail, + text: getCurrentUserDisplayNameOrEmail(), type: 'TEXT', }, ], @@ -3171,14 +3180,14 @@ function buildOptimisticIOUReportAction( actionName: CONST.REPORT.ACTIONS.TYPE.IOU, actorAccountID: currentUserAccountID, automatic: false, - avatar: currentUserPersonalDetails?.avatar ?? UserUtils.getDefaultAvatarURL(currentUserAccountID), + avatar: getCurrentUserAvatarOrDefault(), isAttachment: false, originalMessage, message: getIOUReportActionMessage(iouReportID, type, amount, comment, currency, paymentType, isSettlingUp), person: [ { style: 'strong', - text: currentUserPersonalDetails?.displayName ?? currentUserEmail, + text: getCurrentUserDisplayNameOrEmail(), type: 'TEXT', }, ], @@ -3204,14 +3213,14 @@ function buildOptimisticApprovedReportAction(amount: number, currency: string, e actionName: CONST.REPORT.ACTIONS.TYPE.APPROVED, actorAccountID: currentUserAccountID, automatic: false, - avatar: currentUserPersonalDetails?.avatar ?? UserUtils.getDefaultAvatarURL(currentUserAccountID), + avatar: getCurrentUserAvatarOrDefault(), isAttachment: false, originalMessage, message: getIOUReportActionMessage(expenseReportID, CONST.REPORT.ACTIONS.TYPE.APPROVED, Math.abs(amount), '', currency), person: [ { style: 'strong', - text: currentUserPersonalDetails?.displayName ?? currentUserEmail, + text: getCurrentUserDisplayNameOrEmail(), type: 'TEXT', }, ], @@ -3246,14 +3255,14 @@ function buildOptimisticMovedReportAction(fromPolicyID: string, toPolicyID: stri actionName: CONST.REPORT.ACTIONS.TYPE.MOVED, actorAccountID: currentUserAccountID, automatic: false, - avatar: currentUserPersonalDetails?.avatar ?? UserUtils.getDefaultAvatarURL(currentUserAccountID), + avatar: getCurrentUserAvatarOrDefault(), isAttachment: false, originalMessage, message: movedActionMessage, person: [ { style: 'strong', - text: currentUserPersonalDetails?.displayName ?? currentUserEmail, + text: getCurrentUserDisplayNameOrEmail(), type: 'TEXT', }, ], @@ -3279,14 +3288,14 @@ function buildOptimisticSubmittedReportAction(amount: number, currency: string, actionName: CONST.REPORT.ACTIONS.TYPE.SUBMITTED, actorAccountID: currentUserAccountID, automatic: false, - avatar: currentUserPersonalDetails?.avatar ?? UserUtils.getDefaultAvatar(currentUserAccountID), + avatar: getCurrentUserAvatarOrDefault(), isAttachment: false, originalMessage, message: getIOUReportActionMessage(expenseReportID, CONST.REPORT.ACTIONS.TYPE.SUBMITTED, Math.abs(amount), '', currency), person: [ { style: 'strong', - text: currentUserPersonalDetails?.displayName ?? currentUserEmail, + text: getCurrentUserDisplayNameOrEmail(), type: 'TEXT', }, ], @@ -3352,7 +3361,7 @@ function buildOptimisticModifiedExpenseReportAction( actionName: CONST.REPORT.ACTIONS.TYPE.MODIFIEDEXPENSE, actorAccountID: currentUserAccountID, automatic: false, - avatar: currentUserPersonalDetails?.avatar ?? UserUtils.getDefaultAvatarURL(currentUserAccountID), + avatar: getCurrentUserAvatarOrDefault(), created: DateUtils.getDBTime(), isAttachment: false, message: [ @@ -3435,7 +3444,7 @@ function buildOptimisticTaskReportAction(taskReportID: string, actionName: Origi actionName, actorAccountID: currentUserAccountID, automatic: false, - avatar: currentUserPersonalDetails?.avatar ?? UserUtils.getDefaultAvatarURL(currentUserAccountID), + avatar: getCurrentUserAvatarOrDefault(), isAttachment: false, originalMessage, message: [ @@ -3511,10 +3520,6 @@ function buildOptimisticChatReport( }; } -function getCurrentUserAvatarOrDefault(): UserUtils.AvatarSource { - return allPersonalDetails?.[currentUserAccountID ?? '']?.avatar ?? UserUtils.getDefaultAvatarURL(currentUserAccountID); -} - /** * Returns the necessary reportAction onyx data to indicate that the chat has been created optimistically * @param [created] - Action created time @@ -3541,7 +3546,7 @@ function buildOptimisticCreatedReportAction(emailCreatingAction: string, created { type: CONST.REPORT.MESSAGE.TYPE.TEXT, style: 'strong', - text: allPersonalDetails?.[currentUserAccountID ?? '']?.displayName ?? currentUserEmail, + text: getCurrentUserDisplayNameOrEmail(), }, ], automatic: false, @@ -3577,7 +3582,7 @@ function buildOptimisticRenamedRoomReportAction(newName: string, oldName: string { type: CONST.REPORT.MESSAGE.TYPE.TEXT, style: 'strong', - text: allPersonalDetails?.[currentUserAccountID ?? '']?.displayName ?? currentUserEmail, + text: getCurrentUserDisplayNameOrEmail(), }, ], originalMessage: { @@ -3618,11 +3623,11 @@ function buildOptimisticHoldReportAction(comment: string, created = DateUtils.ge { type: CONST.REPORT.MESSAGE.TYPE.TEXT, style: 'strong', - text: allPersonalDetails?.[currentUserAccountID ?? '']?.displayName ?? currentUserEmail, + text: getCurrentUserDisplayNameOrEmail(), }, ], automatic: false, - avatar: allPersonalDetails?.[currentUserAccountID ?? '']?.avatar ?? UserUtils.getDefaultAvatarURL(currentUserAccountID), + avatar: getCurrentUserAvatarOrDefault(), created, shouldShow: true, }; @@ -3649,42 +3654,79 @@ function buildOptimisticUnHoldReportAction(created = DateUtils.getDBTime()): Opt { type: CONST.REPORT.MESSAGE.TYPE.TEXT, style: 'normal', - text: allPersonalDetails?.[currentUserAccountID ?? '']?.displayName ?? currentUserEmail, + text: getCurrentUserDisplayNameOrEmail(), }, ], automatic: false, - avatar: allPersonalDetails?.[currentUserAccountID ?? '']?.avatar ?? UserUtils.getDefaultAvatarURL(currentUserAccountID), + avatar: getCurrentUserAvatarOrDefault(), created, shouldShow: true, }; } -/** - * Returns the necessary reportAction onyx data to indicate that a task report has been edited - */ -function buildOptimisticEditedTaskReportAction(emailEditingTask: string): OptimisticEditedTaskReportAction { +function buildOptimisticEditedTaskFieldReportAction({title, description}: Task): OptimisticEditedTaskReportAction { + // We do not modify title & description in one request, so we need to create a different optimistic action for each field modification + let field = ''; + let value = ''; + if (title !== undefined) { + field = 'task title'; + value = title; + } else if (description !== undefined) { + field = 'description'; + value = description; + } + + let changelog = 'edited this task'; + if (field && value) { + changelog = `updated the ${field} to ${value}`; + } else if (field) { + changelog = `removed the ${field}`; + } + return { reportActionID: NumberUtils.rand64(), actionName: CONST.REPORT.ACTIONS.TYPE.TASKEDITED, pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, actorAccountID: currentUserAccountID, message: [ + { + type: CONST.REPORT.MESSAGE.TYPE.COMMENT, + text: changelog, + html: changelog, + }, + ], + person: [ { type: CONST.REPORT.MESSAGE.TYPE.TEXT, style: 'strong', - text: emailEditingTask, + text: getCurrentUserDisplayNameOrEmail(), }, + ], + automatic: false, + avatar: getCurrentUserAvatarOrDefault(), + created: DateUtils.getDBTime(), + shouldShow: false, + }; +} + +function buildOptimisticChangedTaskAssigneeReportAction(assigneeAccountID: number): OptimisticEditedTaskReportAction { + return { + reportActionID: NumberUtils.rand64(), + actionName: CONST.REPORT.ACTIONS.TYPE.TASKEDITED, + pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, + actorAccountID: currentUserAccountID, + message: [ { - type: CONST.REPORT.MESSAGE.TYPE.TEXT, - style: 'normal', - text: ' edited this task', + type: CONST.REPORT.MESSAGE.TYPE.COMMENT, + text: `assigned to ${getDisplayNameForParticipant(assigneeAccountID)}`, + html: `assigned to `, }, ], person: [ { type: CONST.REPORT.MESSAGE.TYPE.TEXT, style: 'strong', - text: allPersonalDetails?.[currentUserAccountID ?? '']?.displayName ?? currentUserEmail, + text: getCurrentUserDisplayNameOrEmail(), }, ], automatic: false, @@ -3727,7 +3769,7 @@ function buildOptimisticClosedReportAction(emailClosingReport: string, policyNam { type: CONST.REPORT.MESSAGE.TYPE.TEXT, style: 'strong', - text: allPersonalDetails?.[currentUserAccountID ?? '']?.displayName ?? currentUserEmail, + text: getCurrentUserDisplayNameOrEmail(), }, ], reportActionID: NumberUtils.rand64(), @@ -3992,7 +4034,7 @@ function shouldReportBeInOptionList({ report: OnyxEntry; currentReportId: string; isInGSDMode: boolean; - betas: Beta[]; + betas: OnyxEntry; policies: OnyxCollection; excludeEmptyChats: boolean; doesReportHaveViolations: boolean; @@ -5183,7 +5225,8 @@ export { buildOptimisticClosedReportAction, buildOptimisticCreatedReportAction, buildOptimisticRenamedRoomReportAction, - buildOptimisticEditedTaskReportAction, + buildOptimisticEditedTaskFieldReportAction, + buildOptimisticChangedTaskAssigneeReportAction, buildOptimisticIOUReport, buildOptimisticApprovedReportAction, buildOptimisticMovedReportAction, diff --git a/src/libs/SidebarUtils.ts b/src/libs/SidebarUtils.ts index 8d53e992cb2d..87c0d672dfb8 100644 --- a/src/libs/SidebarUtils.ts +++ b/src/libs/SidebarUtils.ts @@ -328,7 +328,7 @@ function getOptionData({ const newName = lastAction?.originalMessage?.newName ?? ''; result.alternateText = Localize.translate(preferredLocale, 'newRoomPage.roomRenamedTo', {newName}); } else if (ReportActionsUtils.isTaskAction(lastAction)) { - result.alternateText = TaskUtils.getTaskReportActionMessage(lastAction.actionName); + result.alternateText = TaskUtils.getTaskReportActionMessage(lastAction).text; } else if ( lastAction?.actionName === CONST.REPORT.ACTIONS.TYPE.ROOMCHANGELOG.INVITE_TO_ROOM || lastAction?.actionName === CONST.REPORT.ACTIONS.TYPE.ROOMCHANGELOG.REMOVE_FROM_ROOM || diff --git a/src/libs/TaskUtils.ts b/src/libs/TaskUtils.ts index 623d449db885..81a079003d0e 100644 --- a/src/libs/TaskUtils.ts +++ b/src/libs/TaskUtils.ts @@ -3,6 +3,7 @@ import Onyx from 'react-native-onyx'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type {Report} from '@src/types/onyx'; +import type {Message} from '@src/types/onyx/ReportAction'; import type ReportAction from '@src/types/onyx/ReportAction'; import * as CollectionUtils from './CollectionUtils'; import * as Localize from './Localize'; @@ -22,16 +23,21 @@ Onyx.connect({ /** * Given the Task reportAction name, return the appropriate message to be displayed and copied to clipboard. */ -function getTaskReportActionMessage(actionName: string): string { - switch (actionName) { +function getTaskReportActionMessage(action: OnyxEntry): Pick { + switch (action?.actionName) { case CONST.REPORT.ACTIONS.TYPE.TASKCOMPLETED: - return Localize.translateLocal('task.messages.completed'); + return {text: Localize.translateLocal('task.messages.completed')}; case CONST.REPORT.ACTIONS.TYPE.TASKCANCELLED: - return Localize.translateLocal('task.messages.canceled'); + return {text: Localize.translateLocal('task.messages.canceled')}; case CONST.REPORT.ACTIONS.TYPE.TASKREOPENED: - return Localize.translateLocal('task.messages.reopened'); + return {text: Localize.translateLocal('task.messages.reopened')}; + case CONST.REPORT.ACTIONS.TYPE.TASKEDITED: + return { + text: action?.message?.[0].text ?? '', + html: action?.message?.[0].html, + }; default: - return Localize.translateLocal('task.task'); + return {text: Localize.translateLocal('task.task')}; } } diff --git a/src/libs/actions/Task.ts b/src/libs/actions/Task.ts index e328460c37eb..27c7f3e36fd4 100644 --- a/src/libs/actions/Task.ts +++ b/src/libs/actions/Task.ts @@ -399,7 +399,7 @@ function reopenTask(taskReport: OnyxEntry) { function editTask(report: OnyxTypes.Report, {title, description}: OnyxTypes.Task) { // Create the EditedReportAction on the task - const editTaskReportAction = ReportUtils.buildOptimisticEditedTaskReportAction(currentUserEmail); + const editTaskReportAction = ReportUtils.buildOptimisticEditedTaskFieldReportAction({title, description}); // Sometimes title or description is undefined, so we need to check for that, and we provide it to multiple functions const reportName = (title ?? report?.reportName)?.trim(); @@ -429,6 +429,11 @@ function editTask(report: OnyxTypes.Report, {title, description}: OnyxTypes.Task ]; const successData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report.reportID}`, + value: {[editTaskReportAction.reportActionID]: {pendingAction: null}}, + }, { onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.REPORT}${report.reportID}`, @@ -467,16 +472,22 @@ function editTask(report: OnyxTypes.Report, {title, description}: OnyxTypes.Task API.write(WRITE_COMMANDS.EDIT_TASK, parameters, {optimisticData, successData, failureData}); } -function editTaskAssignee(report: OnyxTypes.Report, ownerAccountID: number, assigneeEmail: string, assigneeAccountID = 0, assigneeChatReport: OnyxEntry = null) { +function editTaskAssignee( + report: OnyxTypes.Report, + ownerAccountID: number, + assigneeEmail: string, + assigneeAccountID: number | null = 0, + assigneeChatReport: OnyxEntry = null, +) { // Create the EditedReportAction on the task - const editTaskReportAction = ReportUtils.buildOptimisticEditedTaskReportAction(currentUserEmail); + const editTaskReportAction = ReportUtils.buildOptimisticChangedTaskAssigneeReportAction(assigneeAccountID ?? 0); const reportName = report.reportName?.trim(); let assigneeChatReportOnyxData; const assigneeChatReportID = assigneeChatReport ? assigneeChatReport.reportID : '0'; const optimisticReport: OptimisticReport = { reportName, - managerID: assigneeAccountID || report.managerID, + managerID: assigneeAccountID ?? report.managerID, pendingFields: { ...(assigneeAccountID && {managerID: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE}), }, @@ -499,6 +510,11 @@ function editTaskAssignee(report: OnyxTypes.Report, ownerAccountID: number, assi ]; const successData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report.reportID}`, + value: {[editTaskReportAction.reportActionID]: {pendingAction: null}}, + }, { onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.REPORT}${report.reportID}`, diff --git a/src/libs/calculateAnchorPosition.ts b/src/libs/calculateAnchorPosition.ts index 3dc5924d023a..9fe6e8f018d8 100644 --- a/src/libs/calculateAnchorPosition.ts +++ b/src/libs/calculateAnchorPosition.ts @@ -1,6 +1,6 @@ /* eslint-disable no-restricted-imports */ -import type {Text as RNText, View} from 'react-native'; import type {ValueOf} from 'type-fest'; +import type {ContextMenuAnchor} from '@pages/home/report/ContextMenu/ReportActionContextMenu'; import CONST from '@src/CONST'; import type {AnchorPosition} from '@src/styles'; @@ -13,9 +13,9 @@ type AnchorOrigin = { /** * Gets the x,y position of the passed in component for the purpose of anchoring another component to it. */ -export default function calculateAnchorPosition(anchorComponent: View | RNText, anchorOrigin?: AnchorOrigin): Promise { +export default function calculateAnchorPosition(anchorComponent: ContextMenuAnchor, anchorOrigin?: AnchorOrigin): Promise { return new Promise((resolve) => { - if (!anchorComponent) { + if (!anchorComponent || !('measureInWindow' in anchorComponent)) { resolve({horizontal: 0, vertical: 0}); return; } diff --git a/src/libs/focusTextInputAfterAnimation/index.android.ts b/src/libs/focusTextInputAfterAnimation/index.android.ts index 31c748f5daa4..cca8a6588103 100644 --- a/src/libs/focusTextInputAfterAnimation/index.android.ts +++ b/src/libs/focusTextInputAfterAnimation/index.android.ts @@ -19,7 +19,7 @@ import type FocusTextInputAfterAnimation from './types'; */ const focusTextInputAfterAnimation: FocusTextInputAfterAnimation = (inputRef, animationLength = 0) => { setTimeout(() => { - inputRef.focus(); + inputRef?.focus(); }, animationLength); }; diff --git a/src/libs/focusTextInputAfterAnimation/index.ts b/src/libs/focusTextInputAfterAnimation/index.ts index 3f7c6555b5ce..66d0c35c1a63 100644 --- a/src/libs/focusTextInputAfterAnimation/index.ts +++ b/src/libs/focusTextInputAfterAnimation/index.ts @@ -4,7 +4,7 @@ import type FocusTextInputAfterAnimation from './types'; * This library is a no-op for all platforms except for Android and iOS and will immediately focus the given input without any delays. */ const focusTextInputAfterAnimation: FocusTextInputAfterAnimation = (inputRef) => { - inputRef.focus(); + inputRef?.focus(); }; export default focusTextInputAfterAnimation; diff --git a/src/libs/focusTextInputAfterAnimation/types.ts b/src/libs/focusTextInputAfterAnimation/types.ts index a6a14165598b..bfe29317c1ef 100644 --- a/src/libs/focusTextInputAfterAnimation/types.ts +++ b/src/libs/focusTextInputAfterAnimation/types.ts @@ -1,5 +1,5 @@ import type {TextInput} from 'react-native'; -type FocusTextInputAfterAnimation = (inputRef: TextInput | HTMLInputElement, animationLength: number) => void; +type FocusTextInputAfterAnimation = (inputRef: TextInput | HTMLInputElement | undefined, animationLength: number) => void; export default FocusTextInputAfterAnimation; diff --git a/src/libs/getClickedTargetLocation/types.ts b/src/libs/getClickedTargetLocation/types.ts index 7b1e85e63b17..eed10238be2d 100644 --- a/src/libs/getClickedTargetLocation/types.ts +++ b/src/libs/getClickedTargetLocation/types.ts @@ -1,5 +1,5 @@ type DOMRectProperties = 'top' | 'bottom' | 'left' | 'right' | 'height' | 'x' | 'y'; -type GetClickedTargetLocation = (target: Element) => Pick; +type GetClickedTargetLocation = (target: HTMLDivElement) => Pick; export default GetClickedTargetLocation; diff --git a/src/libs/isReportMessageAttachment.ts b/src/libs/isReportMessageAttachment.ts index fd03adcffd93..330ba4470097 100644 --- a/src/libs/isReportMessageAttachment.ts +++ b/src/libs/isReportMessageAttachment.ts @@ -8,15 +8,15 @@ import type {Message} from '@src/types/onyx/ReportAction'; * * @param reportActionMessage report action's message as text, html and translationKey */ -export default function isReportMessageAttachment({text, html, translationKey}: Message): boolean { - if (!text || !html) { +export default function isReportMessageAttachment(message: Message | undefined): boolean { + if (!message?.text || !message.html) { return false; } - if (translationKey && text === CONST.ATTACHMENT_MESSAGE_TEXT) { - return translationKey === CONST.TRANSLATION_KEYS.ATTACHMENT; + if (message.translationKey && message.text === CONST.ATTACHMENT_MESSAGE_TEXT) { + return message?.translationKey === CONST.TRANSLATION_KEYS.ATTACHMENT; } const regex = new RegExp(` ${CONST.ATTACHMENT_SOURCE_ATTRIBUTE}="(.*)"`, 'i'); - return (text === CONST.ATTACHMENT_MESSAGE_TEXT || !!Str.isVideo(text)) && (!!html.match(regex) || html === CONST.ATTACHMENT_UPLOADING_MESSAGE_HTML); + return (message.text === CONST.ATTACHMENT_MESSAGE_TEXT || !!Str.isVideo(message.text)) && (!!message.html.match(regex) || message.html === CONST.ATTACHMENT_UPLOADING_MESSAGE_HTML); } diff --git a/src/libs/migrations/KeyReportActionsDraftByReportActionID.ts b/src/libs/migrations/KeyReportActionsDraftByReportActionID.ts index dbf2829a6c28..c8ef72ca15e7 100644 --- a/src/libs/migrations/KeyReportActionsDraftByReportActionID.ts +++ b/src/libs/migrations/KeyReportActionsDraftByReportActionID.ts @@ -47,6 +47,7 @@ export default function () { // If newReportActionsDrafts[newOnyxKey] isn't set, fall back on the migrated draft if there is one const currentActionsDrafts = newReportActionsDrafts[newOnyxKey] ?? allReportActionsDrafts[newOnyxKey]; + newReportActionsDrafts[newOnyxKey] = { ...currentActionsDrafts, [reportActionID]: reportActionDraft, diff --git a/src/pages/home/ReportScreenContext.ts b/src/pages/home/ReportScreenContext.ts index e9440ab932d6..6f177098c2c4 100644 --- a/src/pages/home/ReportScreenContext.ts +++ b/src/pages/home/ReportScreenContext.ts @@ -1,8 +1,9 @@ import type {RefObject, SyntheticEvent} from 'react'; import {createContext} from 'react'; -import type {FlatList, GestureResponderEvent, View} from 'react-native'; +// eslint-disable-next-line no-restricted-imports +import type {FlatList, GestureResponderEvent, Text, View} from 'react-native'; -type ReactionListAnchor = View | HTMLDivElement | null; +type ReactionListAnchor = View | Text | HTMLDivElement | null; type ReactionListEvent = GestureResponderEvent | MouseEvent | SyntheticEvent; diff --git a/src/pages/home/report/ContextMenu/BaseReportActionContextMenu.tsx b/src/pages/home/report/ContextMenu/BaseReportActionContextMenu.tsx index 4f6e0548eb72..974a8824f5ff 100755 --- a/src/pages/home/report/ContextMenu/BaseReportActionContextMenu.tsx +++ b/src/pages/home/report/ContextMenu/BaseReportActionContextMenu.tsx @@ -22,7 +22,7 @@ import type {Beta, ReportAction, ReportActions} from '@src/types/onyx'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; import type {ContextMenuAction, ContextMenuActionPayload} from './ContextMenuActions'; import ContextMenuActions from './ContextMenuActions'; -import type {ContextMenuType} from './ReportActionContextMenu'; +import type {ContextMenuAnchor, ContextMenuType} from './ReportActionContextMenu'; import {hideContextMenu, showContextMenu} from './ReportActionContextMenu'; type BaseReportActionContextMenuOnyxProps = { @@ -64,7 +64,7 @@ type BaseReportActionContextMenuProps = BaseReportActionContextMenuOnyxProps & { type?: ContextMenuType; /** Target node which is the target of ContentMenu */ - anchor?: MutableRefObject; + anchor?: MutableRefObject; /** Flag to check if the chat participant is Chronos */ isChronosReport?: boolean; diff --git a/src/pages/home/report/ContextMenu/ContextMenuActions.tsx b/src/pages/home/report/ContextMenu/ContextMenuActions.tsx index 69c29f552104..5099bc6cc542 100644 --- a/src/pages/home/report/ContextMenu/ContextMenuActions.tsx +++ b/src/pages/home/report/ContextMenu/ContextMenuActions.tsx @@ -2,7 +2,8 @@ import ExpensiMark from 'expensify-common/lib/ExpensiMark'; import type {MutableRefObject} from 'react'; import React from 'react'; import {InteractionManager} from 'react-native'; -import type {GestureResponderEvent} from 'react-native'; +// eslint-disable-next-line no-restricted-imports +import type {GestureResponderEvent, Text, View} from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; import type {Emoji} from '@assets/emojis/types'; import * as Expensicons from '@components/Icon/Expensicons'; @@ -30,6 +31,7 @@ import type {TranslationPaths} from '@src/languages/types'; import ROUTES from '@src/ROUTES'; import type {Beta, ReportAction, ReportActionReactions, Report as ReportType} from '@src/types/onyx'; import type IconAsset from '@src/types/utils/IconAsset'; +import type {ContextMenuAnchor} from './ReportActionContextMenu'; import {hideContextMenu, showDeleteModal} from './ReportActionContextMenu'; /** Gets the HTML version of the message in an action */ @@ -54,7 +56,7 @@ type ShouldShow = ( reportAction: OnyxEntry, isArchivedRoom: boolean, betas: OnyxEntry, - menuTarget: MutableRefObject | undefined, + menuTarget: MutableRefObject | undefined, isChronosReport: boolean, reportID: string, isPinnedChat: boolean, @@ -71,6 +73,8 @@ type ContextMenuActionPayload = { close: () => void; openContextMenu: () => void; interceptAnonymousUser: (callback: () => void, isAnonymousAction?: boolean) => void; + anchor?: MutableRefObject; + checkIfContextMenuActive?: () => void; openOverflowMenu: (event: GestureResponderEvent | MouseEvent) => void; event?: GestureResponderEvent | MouseEvent | KeyboardEvent; setIsEmojiPickerActive?: (state: boolean) => void; @@ -346,9 +350,8 @@ const ContextMenuActions: ContextMenuAction[] = [ // `ContextMenuItem` with `successText` and `successIcon` which will fall back to // the `text` and `icon` onPress: (closePopover, {reportAction, selection, reportID}) => { - const isTaskAction = ReportActionsUtils.isTaskAction(reportAction); const isReportPreviewAction = ReportActionsUtils.isReportPreviewAction(reportAction); - const messageHtml = isTaskAction ? TaskUtils.getTaskReportActionMessage(reportAction?.actionName) : getActionHtml(reportAction); + const messageHtml = getActionHtml(reportAction); const messageText = ReportActionsUtils.getReportActionMessageText(reportAction); const isAttachment = ReportActionsUtils.isReportActionAttachment(reportAction); @@ -358,6 +361,9 @@ const ContextMenuActions: ContextMenuAction[] = [ const iouReport = ReportUtils.getReport(ReportActionsUtils.getIOUReportIDFromReportActionPreview(reportAction)); const displayMessage = ReportUtils.getReportPreviewMessage(iouReport, reportAction); Clipboard.setString(displayMessage); + } else if (ReportActionsUtils.isTaskAction(reportAction)) { + const displayMessage = TaskUtils.getTaskReportActionMessage(reportAction).text; + Clipboard.setString(displayMessage); } else if (ReportActionsUtils.isModifiedExpenseAction(reportAction)) { const modifyExpenseMessage = ModifiedExpenseMessage.getForReportAction(reportID, reportAction); Clipboard.setString(modifyExpenseMessage); @@ -407,7 +413,7 @@ const ContextMenuActions: ContextMenuAction[] = [ const isAttachment = ReportActionsUtils.isReportActionAttachment(reportAction); // Only hide the copylink menu item when context menu is opened over img element. - const isAttachmentTarget = menuTarget?.current?.tagName === 'IMG' && isAttachment; + const isAttachmentTarget = menuTarget?.current && 'tagName' in menuTarget.current && menuTarget?.current.tagName === 'IMG' && isAttachment; return Permissions.canUseCommentLinking(betas) && type === CONST.CONTEXT_MENU_TYPES.REPORT_ACTION && !isAttachmentTarget && !ReportActionsUtils.isMessageDeleted(reportAction); }, onPress: (closePopover, {reportAction, reportID}) => { diff --git a/src/pages/home/report/ContextMenu/MiniReportActionContextMenu/types.ts b/src/pages/home/report/ContextMenu/MiniReportActionContextMenu/types.ts index 98b38dcb6968..b7c3d6214094 100644 --- a/src/pages/home/report/ContextMenu/MiniReportActionContextMenu/types.ts +++ b/src/pages/home/report/ContextMenu/MiniReportActionContextMenu/types.ts @@ -1,6 +1,6 @@ import type {BaseReportActionContextMenuProps} from '@pages/home/report/ContextMenu/BaseReportActionContextMenu'; -type MiniReportActionContextMenuProps = Omit & { +type MiniReportActionContextMenuProps = Omit & { /** Should the reportAction this menu is attached to have the appearance of being grouped with the previous reportAction? */ displayAsGroup?: boolean; }; diff --git a/src/pages/home/report/ContextMenu/PopoverReportActionContextMenu.tsx b/src/pages/home/report/ContextMenu/PopoverReportActionContextMenu.tsx index 862d5f01c2fc..931b87704ce5 100644 --- a/src/pages/home/report/ContextMenu/PopoverReportActionContextMenu.tsx +++ b/src/pages/home/report/ContextMenu/PopoverReportActionContextMenu.tsx @@ -67,8 +67,8 @@ function PopoverReportActionContextMenu(_props: unknown, ref: ForwardedRef(null); const anchorRef = useRef(null); const dimensionsEventListener = useRef(null); - const contextMenuAnchorRef = useRef(null); - const contextMenuTargetNode = useRef(null); + const contextMenuAnchorRef = useRef(null); + const contextMenuTargetNode = useRef(null); const onPopoverShow = useRef(() => {}); const onPopoverHide = useRef(() => {}); @@ -83,7 +83,7 @@ function PopoverReportActionContextMenu(_props: unknown, ref: ForwardedRef new Promise((resolve) => { - if (contextMenuAnchorRef.current && typeof contextMenuAnchorRef.current.measureInWindow === 'function') { + if (contextMenuAnchorRef.current && 'measureInWindow' in contextMenuAnchorRef.current && typeof contextMenuAnchorRef.current.measureInWindow === 'function') { contextMenuAnchorRef.current.measureInWindow((x, y) => resolve({x, y})); } else { resolve({x: 0, y: 0}); @@ -169,7 +169,7 @@ function PopoverReportActionContextMenu(_props: unknown, ref: ForwardedRef { const {pageX = 0, pageY = 0} = extractPointerEvent(event); contextMenuAnchorRef.current = contextMenuAnchor; - contextMenuTargetNode.current = event.target as HTMLElement; + contextMenuTargetNode.current = event.target as HTMLDivElement; if (shouldCloseOnTarget) { anchorRef.current = event.target as HTMLDivElement; } else { diff --git a/src/pages/home/report/ContextMenu/ReportActionContextMenu.ts b/src/pages/home/report/ContextMenu/ReportActionContextMenu.ts index f2537c56a5af..21c1eea18e03 100644 --- a/src/pages/home/report/ContextMenu/ReportActionContextMenu.ts +++ b/src/pages/home/report/ContextMenu/ReportActionContextMenu.ts @@ -16,7 +16,7 @@ type OnCancel = () => void; type ContextMenuType = ValueOf; -type ContextMenuAnchor = View | RNText | null | undefined; +type ContextMenuAnchor = View | RNText | HTMLDivElement | null | undefined; type ShowContextMenu = ( type: ContextMenuType, diff --git a/src/pages/home/report/ReportActionItem.js b/src/pages/home/report/ReportActionItem.tsx similarity index 53% rename from src/pages/home/report/ReportActionItem.js rename to src/pages/home/report/ReportActionItem.tsx index 12588845214b..42529e172974 100644 --- a/src/pages/home/report/ReportActionItem.js +++ b/src/pages/home/report/ReportActionItem.tsx @@ -1,9 +1,10 @@ -import lodashGet from 'lodash/get'; -import PropTypes from 'prop-types'; +import lodashIsEqual from 'lodash/isEqual'; import React, {memo, useCallback, useContext, useEffect, useMemo, useRef, useState} from 'react'; +import type {GestureResponderEvent, TextInput} from 'react-native'; import {InteractionManager, View} from 'react-native'; +import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; import {withOnyx} from 'react-native-onyx'; -import _ from 'underscore'; +import type {Emoji} from '@assets/emojis/types'; import Button from '@components/Button'; import DisplayNames from '@components/DisplayNames'; import Hoverable from '@components/Hoverable'; @@ -12,11 +13,11 @@ import * as Expensicons from '@components/Icon/Expensicons'; import InlineSystemMessage from '@components/InlineSystemMessage'; import KYCWall from '@components/KYCWall'; import OfflineWithFeedback from '@components/OfflineWithFeedback'; -import {usePersonalDetails, withBlockedFromConcierge, withNetwork, withReportActionsDrafts} from '@components/OnyxProvider'; +import {useBlockedFromConcierge, usePersonalDetails, useReportActionsDrafts} from '@components/OnyxProvider'; import PressableWithSecondaryInteraction from '@components/PressableWithSecondaryInteraction'; -import EmojiReactionsPropTypes from '@components/Reactions/EmojiReactionsPropTypes'; import ReportActionItemEmojiReactions from '@components/Reactions/ReportActionItemEmojiReactions'; import RenderHTML from '@components/RenderHTML'; +import type {ActionableItem} from '@components/ReportActionItem/ActionableItemButtons'; import ActionableItemButtons from '@components/ReportActionItem/ActionableItemButtons'; import ChronosOOOListActions from '@components/ReportActionItem/ChronosOOOListActions'; import MoneyReportView from '@components/ReportActionItem/MoneyReportView'; @@ -30,14 +31,13 @@ import TaskView from '@components/ReportActionItem/TaskView'; import {ShowContextMenuContext} from '@components/ShowContextMenuContext'; import Text from '@components/Text'; import UnreadActionIndicator from '@components/UnreadActionIndicator'; -import withLocalize from '@components/withLocalize'; -import withWindowDimensions, {windowDimensionsPropTypes} from '@components/withWindowDimensions'; +import useLocalize from '@hooks/useLocalize'; import usePrevious from '@hooks/usePrevious'; import useReportScrollManager from '@hooks/useReportScrollManager'; import useStyleUtils from '@hooks/useStyleUtils'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; -import compose from '@libs/compose'; +import useWindowDimensions from '@hooks/useWindowDimensions'; import ControlSelection from '@libs/ControlSelection'; import * as DeviceCapabilities from '@libs/DeviceCapabilities'; import * as ErrorUtils from '@libs/ErrorUtils'; @@ -49,9 +49,7 @@ import * as PersonalDetailsUtils from '@libs/PersonalDetailsUtils'; import * as ReportActionsUtils from '@libs/ReportActionsUtils'; import * as ReportUtils from '@libs/ReportUtils'; import SelectionScraper from '@libs/SelectionScraper'; -import userWalletPropTypes from '@pages/EnablePayments/userWalletPropTypes'; import {ReactionListContext} from '@pages/home/ReportScreenContext'; -import reportPropTypes from '@pages/reportPropTypes'; import * as BankAccounts from '@userActions/BankAccounts'; import * as EmojiPickerAction from '@userActions/EmojiPickerAction'; import * as store from '@userActions/ReimbursementAccount/store'; @@ -62,6 +60,8 @@ import * as User from '@userActions/User'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; +import type * as OnyxTypes from '@src/types/onyx'; +import {isEmptyObject} from '@src/types/utils/EmptyObject'; import AnimatedEmptyStateBackground from './AnimatedEmptyStateBackground'; import MiniReportActionContextMenu from './ContextMenu/MiniReportActionContextMenu'; import * as ReportActionContextMenu from './ContextMenu/ReportActionContextMenu'; @@ -75,88 +75,113 @@ import ReportActionItemMessage from './ReportActionItemMessage'; import ReportActionItemMessageEdit from './ReportActionItemMessageEdit'; import ReportActionItemSingle from './ReportActionItemSingle'; import ReportActionItemThread from './ReportActionItemThread'; -import reportActionPropTypes from './reportActionPropTypes'; import ReportAttachmentsContext from './ReportAttachmentsContext'; -const propTypes = { - ...windowDimensionsPropTypes, +const getDraftMessage = (drafts: OnyxCollection, reportID: string, action: OnyxTypes.ReportAction): string | undefined => { + const originalReportID = ReportUtils.getOriginalReportID(reportID, action); + const draftKey = `${ONYXKEYS.COLLECTION.REPORT_ACTIONS_DRAFTS}${originalReportID}`; + const draftMessage = drafts?.[draftKey]?.[action.reportActionID]; + return typeof draftMessage === 'string' ? draftMessage : draftMessage?.message; +}; + +type ReportActionItemOnyxProps = { + /** Stores user's preferred skin tone */ + preferredSkinTone: OnyxEntry; + + /** IOU report for this action, if any */ + iouReport: OnyxEntry; + + emojiReactions: OnyxEntry; + + /** The user's wallet account */ + userWallet: OnyxEntry; + + /** All the report actions belonging to the report's parent */ + parentReportActions: OnyxEntry; + + /** All policy report fields */ + policyReportFields: OnyxEntry; + /** The policy which the user has access to and which the report is tied to */ + policy: OnyxEntry; +}; + +type ReportActionItemProps = { /** Report for this action */ - report: reportPropTypes.isRequired, + report: OnyxTypes.Report; /** All the data of the action item */ - action: PropTypes.shape(reportActionPropTypes).isRequired, + action: OnyxTypes.ReportAction; /** Should the comment have the appearance of being grouped with the previous comment? */ - displayAsGroup: PropTypes.bool.isRequired, + displayAsGroup: boolean; /** Is this the most recent IOU Action? */ - isMostRecentIOUReportAction: PropTypes.bool.isRequired, + isMostRecentIOUReportAction: boolean; /** Should we display the new marker on top of the comment? */ - shouldDisplayNewMarker: PropTypes.bool.isRequired, + shouldDisplayNewMarker: boolean; /** Determines if the avatar is displayed as a subscript (positioned lower than normal) */ - shouldShowSubscriptAvatar: PropTypes.bool, + shouldShowSubscriptAvatar?: boolean; /** Position index of the report action in the overall report FlatList view */ - index: PropTypes.number.isRequired, - - /** Draft message - if this is set the comment is in 'edit' mode */ - draftMessage: PropTypes.string, - - /** Stores user's preferred skin tone */ - preferredSkinTone: PropTypes.oneOfType([PropTypes.number, PropTypes.string]), - - emojiReactions: EmojiReactionsPropTypes, - - /** IOU report for this action, if any */ - iouReport: reportPropTypes, + index: number; /** Flag to show, hide the thread divider line */ - shouldHideThreadDividerLine: PropTypes.bool, - - /** The user's wallet account */ - userWallet: userWalletPropTypes, + shouldHideThreadDividerLine?: boolean; - /** All the report actions belonging to the report's parent */ - parentReportActions: PropTypes.objectOf(PropTypes.shape(reportActionPropTypes)), + linkedReportActionID?: string; /** Callback to be called on onPress */ - onPress: PropTypes.func, -}; - -const defaultProps = { - draftMessage: undefined, - preferredSkinTone: CONST.EMOJI_DEFAULT_SKIN_TONE, - emojiReactions: {}, - shouldShowSubscriptAvatar: false, - iouReport: undefined, - shouldHideThreadDividerLine: false, - userWallet: {}, - parentReportActions: {}, - onPress: undefined, -}; - -function ReportActionItem(props) { + onPress?: () => void; +} & ReportActionItemOnyxProps; + +const isIOUReport = (actionObj: OnyxEntry): actionObj is OnyxTypes.ReportActionBase & OnyxTypes.OriginalMessageIOU => + actionObj?.actionName === CONST.REPORT.ACTIONS.TYPE.IOU; + +function ReportActionItem({ + action, + report, + linkedReportActionID, + displayAsGroup, + emojiReactions, + index, + iouReport, + isMostRecentIOUReportAction, + parentReportActions, + preferredSkinTone = CONST.EMOJI_DEFAULT_SKIN_TONE, + shouldDisplayNewMarker, + userWallet, + shouldHideThreadDividerLine = false, + shouldShowSubscriptAvatar = false, + policyReportFields, + policy, + onPress = undefined, +}: ReportActionItemProps) { + const {translate} = useLocalize(); + const {isSmallScreenWidth} = useWindowDimensions(); + const blockedFromConcierge = useBlockedFromConcierge(); + const reportActionDrafts = useReportActionsDrafts(); + const draftMessage = useMemo(() => getDraftMessage(reportActionDrafts, report.reportID, action), [action, report.reportID, reportActionDrafts]); const theme = useTheme(); const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); const personalDetails = usePersonalDetails() || CONST.EMPTY_OBJECT; - const [isContextMenuActive, setIsContextMenuActive] = useState(() => ReportActionContextMenu.isActiveReportAction(props.action.reportActionID)); - const [isEmojiPickerActive, setIsEmojiPickerActive] = useState(); + const [isContextMenuActive, setIsContextMenuActive] = useState(() => ReportActionContextMenu.isActiveReportAction(action.reportActionID)); + const [isEmojiPickerActive, setIsEmojiPickerActive] = useState(); const [isHidden, setIsHidden] = useState(false); - const [moderationDecision, setModerationDecision] = useState(CONST.MODERATION.MODERATOR_DECISION_APPROVED); + const [moderationDecision, setModerationDecision] = useState(CONST.MODERATION.MODERATOR_DECISION_APPROVED); const reactionListRef = useContext(ReactionListContext); const {updateHiddenAttachments} = useContext(ReportAttachmentsContext); - const textInputRef = useRef(); - const popoverAnchorRef = useRef(); - const downloadedPreviews = useRef([]); - const prevDraftMessage = usePrevious(props.draftMessage); - const originalReportID = ReportUtils.getOriginalReportID(props.report.reportID, props.action); - const originalReport = props.report.reportID === originalReportID ? props.report : ReportUtils.getReport(originalReportID); - const isReportActionLinked = props.linkedReportActionID && props.action.reportActionID && props.linkedReportActionID === props.action.reportActionID; + const textInputRef = useRef(); + const popoverAnchorRef = useRef(null); + const downloadedPreviews = useRef([]); + const prevDraftMessage = usePrevious(draftMessage); + const originalReportID = ReportUtils.getOriginalReportID(report.reportID, action); + const originalReport = report.reportID === originalReportID ? report : ReportUtils.getReport(originalReportID); + const isReportActionLinked = linkedReportActionID && action.reportActionID && linkedReportActionID === action.reportActionID; const reportScrollManager = useReportScrollManager(); @@ -164,117 +189,120 @@ function ReportActionItem(props) { () => (isReportActionLinked ? StyleUtils.getBackgroundColorStyle(theme.hoverComponentBG) : {}), [StyleUtils, isReportActionLinked, theme.hoverComponentBG], ); - const originalMessage = lodashGet(props.action, 'originalMessage', {}); - const isDeletedParentAction = ReportActionsUtils.isDeletedParentAction(props.action); - const prevActionResolution = usePrevious(lodashGet(props.action, 'originalMessage.resolution', null)); + + const isDeletedParentAction = ReportActionsUtils.isDeletedParentAction(action); + const prevActionResolution = usePrevious(ReportActionsUtils.isActionableMentionWhisper(action) ? action.originalMessage.resolution : null); // IOUDetails only exists when we are sending money - const isSendingMoney = originalMessage.type === CONST.IOU.REPORT_ACTION_TYPE.PAY && _.has(originalMessage, 'IOUDetails'); + const isSendingMoney = isIOUReport(action) && action.originalMessage.type === CONST.IOU.REPORT_ACTION_TYPE.PAY && action.originalMessage.IOUDetails; const updateHiddenState = useCallback( - (isHiddenValue) => { + (isHiddenValue: boolean) => { setIsHidden(isHiddenValue); - const isAttachment = ReportUtils.isReportMessageAttachment(_.last(props.action.message)); + const isAttachment = ReportUtils.isReportMessageAttachment(action.message?.at(-1)); if (!isAttachment) { return; } - updateHiddenAttachments(props.action.reportActionID, isHiddenValue); + updateHiddenAttachments(action.reportActionID, isHiddenValue); }, - [props.action.reportActionID, props.action.message, updateHiddenAttachments], + [action.reportActionID, action.message, updateHiddenAttachments], ); useEffect( () => () => { // ReportActionContextMenu, EmojiPicker and PopoverReactionList are global components, // we should also hide them when the current component is destroyed - if (ReportActionContextMenu.isActiveReportAction(props.action.reportActionID)) { + if (ReportActionContextMenu.isActiveReportAction(action.reportActionID)) { ReportActionContextMenu.hideContextMenu(); ReportActionContextMenu.hideDeleteModal(); } - if (EmojiPickerAction.isActive(props.action.reportActionID)) { + if (EmojiPickerAction.isActive(action.reportActionID)) { EmojiPickerAction.hideEmojiPicker(true); } - if (reactionListRef.current && reactionListRef.current.isActiveReportAction(props.action.reportActionID)) { - reactionListRef.current.hideReactionList(); + if (reactionListRef?.current?.isActiveReportAction(action.reportActionID)) { + reactionListRef?.current?.hideReactionList(); } }, - [props.action.reportActionID, reactionListRef], + [action.reportActionID, reactionListRef], ); useEffect(() => { // We need to hide EmojiPicker when this is a deleted parent action - if (!isDeletedParentAction || !EmojiPickerAction.isActive(props.action.reportActionID)) { + if (!isDeletedParentAction || !EmojiPickerAction.isActive(action.reportActionID)) { return; } EmojiPickerAction.hideEmojiPicker(true); - }, [isDeletedParentAction, props.action.reportActionID]); + }, [isDeletedParentAction, action.reportActionID]); useEffect(() => { - if (!_.isUndefined(prevDraftMessage) || _.isUndefined(props.draftMessage)) { + if (prevDraftMessage !== undefined || draftMessage === undefined) { return; } focusTextInputAfterAnimation(textInputRef.current, 100); - }, [prevDraftMessage, props.draftMessage]); + }, [prevDraftMessage, draftMessage]); useEffect(() => { if (!Permissions.canUseLinkPreviews()) { return; } - const urls = ReportActionsUtils.extractLinksFromMessageHtml(props.action); - if (_.isEqual(downloadedPreviews.current, urls) || props.action.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE) { + const urls = ReportActionsUtils.extractLinksFromMessageHtml(action); + if (lodashIsEqual(downloadedPreviews.current, urls) || action.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE) { return; } downloadedPreviews.current = urls; - Report.expandURLPreview(props.report.reportID, props.action.reportActionID); - }, [props.action, props.report.reportID]); + Report.expandURLPreview(report.reportID, action.reportActionID); + }, [action, report.reportID]); useEffect(() => { - if (_.isUndefined(props.draftMessage) || !ReportActionsUtils.isDeletedAction(props.action)) { + if (draftMessage === undefined || !ReportActionsUtils.isDeletedAction(action)) { return; } - Report.deleteReportActionDraft(props.report.reportID, props.action); - }, [props.draftMessage, props.action, props.report.reportID]); + Report.deleteReportActionDraft(report.reportID, action); + }, [draftMessage, action, report.reportID]); // Hide the message if it is being moderated for a higher offense, or is hidden by a moderator // Removed messages should not be shown anyway and should not need this flow - const latestDecision = lodashGet(props, ['action', 'message', 0, 'moderationDecision', 'decision'], ''); + const latestDecision = action.message?.[0].moderationDecision?.decision ?? ''; useEffect(() => { - if (props.action.actionName !== CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT) { + if (action.actionName !== CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT) { return; } // Hide reveal message button and show the message if latestDecision is changed to empty - if (_.isEmpty(latestDecision)) { + if (!latestDecision) { setModerationDecision(CONST.MODERATION.MODERATOR_DECISION_APPROVED); setIsHidden(false); return; } setModerationDecision(latestDecision); - if (!_.contains([CONST.MODERATION.MODERATOR_DECISION_APPROVED, CONST.MODERATION.MODERATOR_DECISION_PENDING], latestDecision) && !ReportActionsUtils.isPendingRemove(props.action)) { + if ( + ![CONST.MODERATION.MODERATOR_DECISION_APPROVED, CONST.MODERATION.MODERATOR_DECISION_PENDING].some((item) => item === latestDecision) && + !ReportActionsUtils.isPendingRemove(action) + ) { setIsHidden(true); return; } setIsHidden(false); - }, [latestDecision, props.action]); + }, [latestDecision, action]); const toggleContextMenuFromActiveReportAction = useCallback(() => { - setIsContextMenuActive(ReportActionContextMenu.isActiveReportAction(props.action.reportActionID)); - }, [props.action.reportActionID]); + setIsContextMenuActive(ReportActionContextMenu.isActiveReportAction(action.reportActionID)); + }, [action.reportActionID]); /** * Show the ReportActionContextMenu modal popover. * - * @param {Object} [event] - A press event. + * @param [event] - A press event. */ const showPopover = useCallback( - (event) => { + (event: GestureResponderEvent | MouseEvent) => { // Block menu on the message being Edited or if the report action item has errors - if (!_.isUndefined(props.draftMessage) || !_.isEmpty(props.action.errors)) { + if (draftMessage !== undefined || !isEmptyObject(action.errors)) { return; } @@ -284,11 +312,11 @@ function ReportActionItem(props) { CONST.CONTEXT_MENU_TYPES.REPORT_ACTION, event, selection, - popoverAnchorRef, - props.report.reportID, - props.action.reportActionID, + popoverAnchorRef.current, + report.reportID, + action.reportActionID, originalReportID, - props.draftMessage, + draftMessage ?? '', () => setIsContextMenuActive(true), toggleContextMenuFromActiveReportAction, ReportUtils.isArchivedRoom(originalReport), @@ -297,168 +325,163 @@ function ReportActionItem(props) { false, [], false, - setIsEmojiPickerActive, + setIsEmojiPickerActive as () => void, ); }, - [props.draftMessage, props.action, props.report.reportID, toggleContextMenuFromActiveReportAction, originalReport, originalReportID], + [draftMessage, action, report.reportID, toggleContextMenuFromActiveReportAction, originalReport, originalReportID], ); // Handles manual scrolling to the bottom of the chat when the last message is an actionable mention whisper and it's resolved. // This fixes an issue where InvertedFlatList fails to auto scroll down and results in an empty space at the bottom of the chat in IOS. useEffect(() => { - if (props.index !== 0 || !ReportActionsUtils.isActionableMentionWhisper(props.action)) { + if (index !== 0 || !ReportActionsUtils.isActionableMentionWhisper(action)) { return; } - if (prevActionResolution !== lodashGet(props.action, 'originalMessage.resolution', null)) { - reportScrollManager.scrollToIndex(props.index); + if (ReportActionsUtils.isActionableMentionWhisper(action) && prevActionResolution !== (action.originalMessage.resolution ?? null)) { + reportScrollManager.scrollToIndex(index); } - }, [props.index, props.action, prevActionResolution, reportScrollManager]); + }, [index, action, prevActionResolution, reportScrollManager]); const toggleReaction = useCallback( - (emoji) => { - Report.toggleEmojiReaction(props.report.reportID, props.action, emoji, props.emojiReactions); + (emoji: Emoji) => { + Report.toggleEmojiReaction(report.reportID, action, emoji, emojiReactions); }, - [props.report, props.action, props.emojiReactions], + [report, action, emojiReactions], ); const contextValue = useMemo( () => ({ - anchor: popoverAnchorRef, - report: props.report, - action: props.action, + anchor: popoverAnchorRef.current, + report, + action, checkIfContextMenuActive: toggleContextMenuFromActiveReportAction, }), - [props.report, props.action, toggleContextMenuFromActiveReportAction], + [report, action, toggleContextMenuFromActiveReportAction], ); - const actionableItemButtons = useMemo(() => { - if (!(ReportActionsUtils.isActionableMentionWhisper(props.action) && !lodashGet(props.action, 'originalMessage.resolution', null))) { + const actionableItemButtons: ActionableItem[] = useMemo(() => { + if (!(ReportActionsUtils.isActionableMentionWhisper(action) && (!action.originalMessage?.resolution ?? null))) { return []; } return [ { text: 'actionableMentionWhisperOptions.invite', - key: `${props.action.reportActionID}-actionableMentionWhisper-${CONST.REPORT.ACTIONABLE_MENTION_WHISPER_RESOLUTION.INVITE}`, - onPress: () => Report.resolveActionableMentionWhisper(props.report.reportID, props.action, CONST.REPORT.ACTIONABLE_MENTION_WHISPER_RESOLUTION.INVITE), + key: `${action.reportActionID}-actionableMentionWhisper-${CONST.REPORT.ACTIONABLE_MENTION_WHISPER_RESOLUTION.INVITE}`, + onPress: () => Report.resolveActionableMentionWhisper(report.reportID, action, CONST.REPORT.ACTIONABLE_MENTION_WHISPER_RESOLUTION.INVITE), isPrimary: true, }, { text: 'actionableMentionWhisperOptions.nothing', - key: `${props.action.reportActionID}-actionableMentionWhisper-${CONST.REPORT.ACTIONABLE_MENTION_WHISPER_RESOLUTION.NOTHING}`, - onPress: () => Report.resolveActionableMentionWhisper(props.report.reportID, props.action, CONST.REPORT.ACTIONABLE_MENTION_WHISPER_RESOLUTION.NOTHING), + key: `${action.reportActionID}-actionableMentionWhisper-${CONST.REPORT.ACTIONABLE_MENTION_WHISPER_RESOLUTION.NOTHING}`, + onPress: () => Report.resolveActionableMentionWhisper(report.reportID, action, CONST.REPORT.ACTIONABLE_MENTION_WHISPER_RESOLUTION.NOTHING), }, ]; - }, [props.action, props.report.reportID]); + }, [action, report.reportID]); /** * Get the content of ReportActionItem - * @param {Boolean} hovered whether the ReportActionItem is hovered - * @param {Boolean} isWhisper whether the report action is a whisper - * @param {Boolean} hasErrors whether the report action has any errors - * @returns {Object} child component(s) + * @param hovered whether the ReportActionItem is hovered + * @param isWhisper whether the report action is a whisper + * @param hasErrors whether the report action has any errors + * @returns child component(s) */ - const renderItemContent = (hovered = false, isWhisper = false, hasErrors = false) => { + const renderItemContent = (hovered = false, isWhisper = false, hasErrors = false): React.JSX.Element => { let children; // Show the MoneyRequestPreview for when request was created, bill was split or money was sent if ( - props.action.actionName === CONST.REPORT.ACTIONS.TYPE.IOU && - originalMessage && + isIOUReport(action) && + action.originalMessage && // For the pay flow, we only want to show MoneyRequestAction when sending money. When paying, we display a regular system message - (originalMessage.type === CONST.IOU.REPORT_ACTION_TYPE.CREATE || originalMessage.type === CONST.IOU.REPORT_ACTION_TYPE.SPLIT || isSendingMoney) + (action.originalMessage.type === CONST.IOU.REPORT_ACTION_TYPE.CREATE || action.originalMessage.type === CONST.IOU.REPORT_ACTION_TYPE.SPLIT || isSendingMoney) ) { // There is no single iouReport for bill splits, so only 1:1 requests require an iouReportID - const iouReportID = originalMessage.IOUReportID ? originalMessage.IOUReportID.toString() : '0'; + const iouReportID = action.originalMessage.IOUReportID ? action.originalMessage.IOUReportID.toString() : '0'; children = ( ); - } else if (props.action.actionName === CONST.REPORT.ACTIONS.TYPE.REPORTPREVIEW) { + } else if (action.actionName === CONST.REPORT.ACTIONS.TYPE.REPORTPREVIEW) { children = ( ); - } else if ( - props.action.actionName === CONST.REPORT.ACTIONS.TYPE.TASKCOMPLETED || - props.action.actionName === CONST.REPORT.ACTIONS.TYPE.TASKCANCELLED || - props.action.actionName === CONST.REPORT.ACTIONS.TYPE.TASKREOPENED - ) { - children = ; - } else if (ReportActionsUtils.isCreatedTaskReportAction(props.action)) { + } else if (ReportActionsUtils.isTaskAction(action)) { + children = ; + } else if (ReportActionsUtils.isCreatedTaskReportAction(action)) { children = ( ); - } else if (props.action.actionName === CONST.REPORT.ACTIONS.TYPE.REIMBURSEMENTQUEUED) { - const submitterDisplayName = PersonalDetailsUtils.getDisplayNameOrDefault(lodashGet(personalDetails, props.report.ownerAccountID)); - const paymentType = lodashGet(props.action, 'originalMessage.paymentType', ''); + } else if (action.actionName === CONST.REPORT.ACTIONS.TYPE.REIMBURSEMENTQUEUED) { + const submitterDisplayName = PersonalDetailsUtils.getDisplayNameOrDefault(personalDetails[report.ownerAccountID ?? -1]); + const paymentType = action.originalMessage.paymentType ?? ''; - const isSubmitterOfUnsettledReport = ReportUtils.isCurrentUserSubmitter(props.report.reportID) && !ReportUtils.isSettled(props.report.reportID); + const isSubmitterOfUnsettledReport = ReportUtils.isCurrentUserSubmitter(report.reportID) && !ReportUtils.isSettled(report.reportID); const shouldShowAddCreditBankAccountButton = isSubmitterOfUnsettledReport && !store.hasCreditBankAccount() && paymentType !== CONST.IOU.PAYMENT_TYPE.EXPENSIFY; const shouldShowEnableWalletButton = - isSubmitterOfUnsettledReport && - (_.isEmpty(props.userWallet) || props.userWallet.tierName === CONST.WALLET.TIER_NAME.SILVER) && - paymentType === CONST.IOU.PAYMENT_TYPE.EXPENSIFY; + isSubmitterOfUnsettledReport && (isEmptyObject(userWallet) || userWallet?.tierName === CONST.WALLET.TIER_NAME.SILVER) && paymentType === CONST.IOU.PAYMENT_TYPE.EXPENSIFY; children = ( <> {shouldShowAddCreditBankAccountButton && ( )} @@ -508,49 +531,46 @@ function ReportActionItem(props) { for example: Invite a user mentioned but not a member of the room https://github.com/Expensify/App/issues/32741 */} - {actionableItemButtons.length > 0 && ( - - )} + {actionableItemButtons.length > 0 && } ) : ( )} ); } - const numberOfThreadReplies = _.get(props, ['action', 'childVisibleActionCount'], 0); + const numberOfThreadReplies = action.childVisibleActionCount ?? 0; - const shouldDisplayThreadReplies = ReportUtils.shouldDisplayThreadReplies(props.action, props.report.reportID); - const oldestFourAccountIDs = _.map(lodashGet(props.action, 'childOldestFourAccountIDs', '').split(','), (accountID) => Number(accountID)); - const draftMessageRightAlign = !_.isUndefined(props.draftMessage) ? styles.chatItemReactionsDraftRight : {}; + const shouldDisplayThreadReplies = ReportUtils.shouldDisplayThreadReplies(action, report.reportID); + const oldestFourAccountIDs = + action.childOldestFourAccountIDs + ?.split(',') + .map((accountID) => Number(accountID)) + .filter((accountID): accountID is number => typeof accountID === 'number') ?? []; + const draftMessageRightAlign = draftMessage !== undefined ? styles.chatItemReactionsDraftRight : {}; return ( <> {children} - {Permissions.canUseLinkPreviews() && !isHidden && !_.isEmpty(props.action.linkMetadata) && ( - - !_.isEmpty(item))} /> + {Permissions.canUseLinkPreviews() && !isHidden && (action.linkMetadata?.length ?? 0) > 0 && ( + + !isEmptyObject(item))} /> )} - {!ReportActionsUtils.isMessageDeleted(props.action) && ( + {!ReportActionsUtils.isMessageDeleted(action) && ( { if (Session.isAnonymousUser()) { @@ -571,9 +591,9 @@ function ReportActionItem(props) { {shouldDisplayThreadReplies && ( { + const renderReportActionItem = (hovered: boolean, isWhisper: boolean, hasErrors: boolean): React.JSX.Element => { const content = renderItemContent(hovered || isContextMenuActive || isEmojiPickerActive, isWhisper, hasErrors); - if (!_.isUndefined(props.draftMessage)) { + if (draftMessage !== undefined) { return {content}; } - if (!props.displayAsGroup) { + if (!displayAsGroup) { return ( item === moderationDecision) && + !ReportActionsUtils.isPendingRemove(action) } > {content} @@ -621,23 +641,23 @@ function ReportActionItem(props) { return {content}; }; - if (props.action.actionName === CONST.REPORT.ACTIONS.TYPE.CREATED) { - const parentReportAction = props.parentReportActions[props.report.parentReportActionID]; + if (action.actionName === CONST.REPORT.ACTIONS.TYPE.CREATED) { + const parentReportAction = parentReportActions?.[report.parentReportActionID ?? ''] ?? null; if (ReportActionsUtils.isTransactionThread(parentReportAction)) { const isReversedTransaction = ReportActionsUtils.isReversedTransaction(parentReportAction); if (ReportActionsUtils.isDeletedParentAction(parentReportAction) || isReversedTransaction) { return ( - + - - + + ${props.translate(isReversedTransaction ? 'parentReportAction.reversedTransaction' : 'parentReportAction.deletedRequest')}`} + html={`${translate(isReversedTransaction ? 'parentReportAction.reversedTransaction' : 'parentReportAction.deletedRequest')}`} /> @@ -649,25 +669,25 @@ function ReportActionItem(props) { return ( ); } - if (ReportUtils.isTaskReport(props.report)) { - if (ReportUtils.isCanceledTaskReport(props.report, parentReportAction)) { + if (ReportUtils.isTaskReport(report)) { + if (ReportUtils.isCanceledTaskReport(report, parentReportAction)) { return ( - + - - + + - ${props.translate('parentReportAction.deletedTask')}`} /> + ${translate('parentReportAction.deletedTask')}`} /> @@ -676,25 +696,25 @@ function ReportActionItem(props) { ); } return ( - + - + ); } - if (ReportUtils.isExpenseReport(props.report) || ReportUtils.isIOUReport(props.report)) { + if (ReportUtils.isExpenseReport(report) || ReportUtils.isIOUReport(report)) { return ( - + ); @@ -702,96 +722,94 @@ function ReportActionItem(props) { return ( ); } - if (props.action.actionName === CONST.REPORT.ACTIONS.TYPE.RENAMED) { - return ; + if (action.actionName === CONST.REPORT.ACTIONS.TYPE.RENAMED) { + return ; } - if (props.action.actionName === CONST.REPORT.ACTIONS.TYPE.CHRONOSOOOLIST) { + if (action.actionName === CONST.REPORT.ACTIONS.TYPE.CHRONOSOOOLIST) { return ( ); } // For the `pay` IOU action on non-send money flow, we don't want to render anything if `isWaitingOnBankAccount` is true // Otherwise, we will see two system messages informing the payee needs to add a bank account or wallet - if ( - props.action.actionName === CONST.REPORT.ACTIONS.TYPE.IOU && - lodashGet(props.report, 'isWaitingOnBankAccount', false) && - originalMessage && - originalMessage.type === CONST.IOU.REPORT_ACTION_TYPE.PAY && - !isSendingMoney - ) { + if (isIOUReport(action) && !!report?.isWaitingOnBankAccount && action.originalMessage.type === CONST.IOU.REPORT_ACTION_TYPE.PAY && !isSendingMoney) { return null; } // if action is actionable mention whisper and resolved by user, then we don't want to render anything - if (ReportActionsUtils.isActionableMentionWhisper(props.action) && lodashGet(props.action, 'originalMessage.resolution', null)) { + if (ReportActionsUtils.isActionableMentionWhisper(action) && (action.originalMessage.resolution ?? null)) { return null; } // We currently send whispers to all report participants and hide them in the UI for users that shouldn't see them. // This is a temporary solution needed for comment-linking. // The long term solution will leverage end-to-end encryption and only targeted users will be able to decrypt. - if (ReportActionsUtils.isWhisperActionTargetedToOthers(props.action)) { + if (ReportActionsUtils.isWhisperActionTargetedToOthers(action)) { return null; } - const hasErrors = !_.isEmpty(props.action.errors); - const whisperedToAccountIDs = props.action.whisperedToAccountIDs || []; + const hasErrors = !isEmptyObject(action.errors); + const whisperedToAccountIDs = action.whisperedToAccountIDs ?? []; const isWhisper = whisperedToAccountIDs.length > 0; const isMultipleParticipant = whisperedToAccountIDs.length > 1; const isWhisperOnlyVisibleByUser = isWhisper && ReportUtils.isCurrentUserTheOnlyParticipant(whisperedToAccountIDs); - const whisperedToPersonalDetails = isWhisper ? _.filter(personalDetails, (details) => _.includes(whisperedToAccountIDs, details.accountID)) : []; + const whisperedToPersonalDetails = isWhisper + ? (Object.values(personalDetails ?? {}).filter((details) => whisperedToAccountIDs.includes(details?.accountID ?? -1)) as OnyxTypes.PersonalDetails[]) + : []; const displayNamesWithTooltips = isWhisper ? ReportUtils.getDisplayNamesWithTooltips(whisperedToPersonalDetails, isMultipleParticipant) : []; return ( props.isSmallScreenWidth && DeviceCapabilities.canUseTouchScreen() && ControlSelection.block()} + onPress={onPress} + style={[action.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE ? styles.pointerEventsNone : styles.pointerEventsAuto]} + onPressIn={() => isSmallScreenWidth && DeviceCapabilities.canUseTouchScreen() && ControlSelection.block()} onPressOut={() => ControlSelection.unblock()} onSecondaryInteraction={showPopover} - preventDefaultContextMenu={_.isUndefined(props.draftMessage) && !hasErrors} + preventDefaultContextMenu={draftMessage === undefined && !hasErrors} withoutFocusOnSecondaryInteraction - accessibilityLabel={props.translate('accessibilityHints.chatMessage')} + accessibilityLabel={translate('accessibilityHints.chatMessage')} + accessible > {(hovered) => ( - {props.shouldDisplayNewMarker && } + {shouldDisplayNewMarker && } - + ReportActions.clearReportActionErrors(props.report.reportID, props.action)} + onClose={() => ReportActions.clearReportActionErrors(report.reportID, action)} + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing pendingAction={ - !_.isUndefined(props.draftMessage) ? null : props.action.pendingAction || (props.action.isOptimisticAction ? CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD : '') + draftMessage !== undefined ? undefined : action.pendingAction ?? (action.isOptimisticAction ? CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD : undefined) } - shouldHideOnDelete={!ReportActionsUtils.isThreadParentMessage(props.action, props.report.reportID)} - errors={ErrorUtils.getLatestErrorMessageField(props.action)} + shouldHideOnDelete={!ReportActionsUtils.isThreadParentMessage(action, report.reportID)} + errors={ErrorUtils.getLatestErrorMessageField(action as ErrorUtils.OnyxDataWithErrors)} errorRowStyles={[styles.ml10, styles.mr2]} - needsOffscreenAlphaCompositing={ReportActionsUtils.isMoneyRequestAction(props.action)} + needsOffscreenAlphaCompositing={ReportActionsUtils.isMoneyRequestAction(action)} shouldDisableStrikeThrough > {isWhisper && ( @@ -804,11 +822,11 @@ function ReportActionItem(props) { /> - {props.translate('reportActionContextMenu.onlyVisible')} + {translate('reportActionContextMenu.onlyVisible')}   )} - {renderReportActionItem(hovered || isReportActionLinked, isWhisper, hasErrors)} + {renderReportActionItem(!!hovered || !!isReportActionLinked, isWhisper, hasErrors)} )} - + {/* @ts-expect-error TODO check if there is a field on the reportAction object */} + ); } -ReportActionItem.propTypes = propTypes; -ReportActionItem.defaultProps = defaultProps; - -export default compose( - withWindowDimensions, - withLocalize, - withNetwork(), - withBlockedFromConcierge({propName: 'blockedFromConcierge'}), - withReportActionsDrafts({ - propName: 'draftMessage', - transformValue: (drafts, props) => { - const originalReportID = ReportUtils.getOriginalReportID(props.report.reportID, props.action); - const draftKey = `${ONYXKEYS.COLLECTION.REPORT_ACTIONS_DRAFTS}${originalReportID}`; - return lodashGet(drafts, [draftKey, props.action.reportActionID, 'message']); +export default withOnyx({ + preferredSkinTone: { + key: ONYXKEYS.PREFERRED_EMOJI_SKIN_TONE, + initialValue: CONST.EMOJI_DEFAULT_SKIN_TONE, + }, + iouReport: { + key: ({action}) => { + const iouReportID = ReportActionsUtils.getIOUReportIDFromReportActionPreview(action); + return `${ONYXKEYS.COLLECTION.REPORT}${iouReportID ?? ''}`; }, - }), - withOnyx({ - preferredSkinTone: { - key: ONYXKEYS.PREFERRED_EMOJI_SKIN_TONE, - initialValue: CONST.EMOJI_DEFAULT_SKIN_TONE, - }, - iouReport: { - key: ({action}) => { - const iouReportID = ReportActionsUtils.getIOUReportIDFromReportActionPreview(action); - return iouReportID ? `${ONYXKEYS.COLLECTION.REPORT}${iouReportID}` : undefined; - }, - initialValue: {}, - }, - policyReportFields: { - key: ({report}) => (report && 'policyID' in report ? `${ONYXKEYS.COLLECTION.POLICY_REPORT_FIELDS}${report.policyID}` : undefined), - initialValue: [], - }, - policy: { - key: ({report}) => (report && 'policyID' in report ? `${ONYXKEYS.COLLECTION.POLICY}${report.policyID}` : undefined), - initialValue: {}, - }, - emojiReactions: { - key: ({action}) => `${ONYXKEYS.COLLECTION.REPORT_ACTIONS_REACTIONS}${action.reportActionID}`, - initialValue: {}, - }, - userWallet: { - key: ONYXKEYS.USER_WALLET, - }, - parentReportActions: { - key: ({report}) => `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report.parentReportID || 0}`, - canEvict: false, - }, - }), -)( + initialValue: {} as OnyxTypes.Report, + }, + policyReportFields: { + key: ({report}) => `${ONYXKEYS.COLLECTION.POLICY_REPORT_FIELDS}${report.policyID ?? ''}`, + initialValue: {}, + }, + policy: { + key: ({report}) => `${ONYXKEYS.COLLECTION.POLICY}${report.policyID ?? ''}`, + initialValue: {} as OnyxTypes.Policy, + }, + emojiReactions: { + key: ({action}) => `${ONYXKEYS.COLLECTION.REPORT_ACTIONS_REACTIONS}${action.reportActionID}`, + initialValue: {}, + }, + userWallet: { + key: ONYXKEYS.USER_WALLET, + }, + parentReportActions: { + key: ({report}) => `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report.parentReportID ?? 0}`, + canEvict: false, + }, +})( memo(ReportActionItem, (prevProps, nextProps) => { - const prevParentReportAction = prevProps.parentReportActions[prevProps.report.parentReportActionID]; - const nextParentReportAction = nextProps.parentReportActions[nextProps.report.parentReportActionID]; + const prevParentReportAction = prevProps.parentReportActions?.[prevProps.report.parentReportActionID ?? '']; + const nextParentReportAction = nextProps.parentReportActions?.[nextProps.report.parentReportActionID ?? '']; return ( prevProps.displayAsGroup === nextProps.displayAsGroup && - prevProps.draftMessage === nextProps.draftMessage && prevProps.isMostRecentIOUReportAction === nextProps.isMostRecentIOUReportAction && prevProps.shouldDisplayNewMarker === nextProps.shouldDisplayNewMarker && - _.isEqual(prevProps.emojiReactions, nextProps.emojiReactions) && - _.isEqual(prevProps.action, nextProps.action) && - _.isEqual(prevProps.iouReport, nextProps.iouReport) && - _.isEqual(prevProps.report.pendingFields, nextProps.report.pendingFields) && - _.isEqual(prevProps.report.isDeletedParentAction, nextProps.report.isDeletedParentAction) && - _.isEqual(prevProps.report.errorFields, nextProps.report.errorFields) && - lodashGet(prevProps.report, 'statusNum') === lodashGet(nextProps.report, 'statusNum') && - lodashGet(prevProps.report, 'stateNum') === lodashGet(nextProps.report, 'stateNum') && - lodashGet(prevProps.report, 'parentReportID') === lodashGet(nextProps.report, 'parentReportID') && - lodashGet(prevProps.report, 'parentReportActionID') === lodashGet(nextProps.report, 'parentReportActionID') && - prevProps.translate === nextProps.translate && + lodashIsEqual(prevProps.emojiReactions, nextProps.emojiReactions) && + lodashIsEqual(prevProps.action, nextProps.action) && + lodashIsEqual(prevProps.iouReport, nextProps.iouReport) && + lodashIsEqual(prevProps.report.pendingFields, nextProps.report.pendingFields) && + lodashIsEqual(prevProps.report.isDeletedParentAction, nextProps.report.isDeletedParentAction) && + lodashIsEqual(prevProps.report.errorFields, nextProps.report.errorFields) && + prevProps.report?.statusNum === nextProps.report?.statusNum && + prevProps.report?.stateNum === nextProps.report?.stateNum && + prevProps.report?.parentReportID === nextProps.report?.parentReportID && + prevProps.report?.parentReportActionID === nextProps.report?.parentReportActionID && // TaskReport's created actions render the TaskView, which updates depending on certain fields in the TaskReport ReportUtils.isTaskReport(prevProps.report) === ReportUtils.isTaskReport(nextProps.report) && prevProps.action.actionName === nextProps.action.actionName && @@ -906,13 +906,13 @@ export default compose( ReportUtils.isCompletedTaskReport(prevProps.report) === ReportUtils.isCompletedTaskReport(nextProps.report) && prevProps.report.managerID === nextProps.report.managerID && prevProps.shouldHideThreadDividerLine === nextProps.shouldHideThreadDividerLine && - lodashGet(prevProps.report, 'total', 0) === lodashGet(nextProps.report, 'total', 0) && - lodashGet(prevProps.report, 'nonReimbursableTotal', 0) === lodashGet(nextProps.report, 'nonReimbursableTotal', 0) && + prevProps.report?.total === nextProps.report?.total && + prevProps.report?.nonReimbursableTotal === nextProps.report?.nonReimbursableTotal && prevProps.linkedReportActionID === nextProps.linkedReportActionID && - _.isEqual(prevProps.policyReportFields, nextProps.policyReportFields) && - _.isEqual(prevProps.report.reportFields, nextProps.report.reportFields) && - _.isEqual(prevProps.policy, nextProps.policy) && - _.isEqual(prevParentReportAction, nextParentReportAction) + lodashIsEqual(prevProps.policyReportFields, nextProps.policyReportFields) && + lodashIsEqual(prevProps.report.reportFields, nextProps.report.reportFields) && + lodashIsEqual(prevProps.policy, nextProps.policy) && + lodashIsEqual(prevParentReportAction, nextParentReportAction) ); }), ); diff --git a/src/pages/home/report/ReportActionItemBasicMessage.tsx b/src/pages/home/report/ReportActionItemBasicMessage.tsx index 35141a42b726..a28f2af24448 100644 --- a/src/pages/home/report/ReportActionItemBasicMessage.tsx +++ b/src/pages/home/report/ReportActionItemBasicMessage.tsx @@ -5,7 +5,7 @@ import Text from '@components/Text'; import useThemeStyles from '@hooks/useThemeStyles'; import type ChildrenProps from '@src/types/utils/ChildrenProps'; -type ReportActionItemBasicMessageProps = ChildrenProps & { +type ReportActionItemBasicMessageProps = Partial & { message: string; }; diff --git a/src/pages/home/report/ReportActionItemCreated.tsx b/src/pages/home/report/ReportActionItemCreated.tsx index 95578c10e816..4fe52f6adf41 100644 --- a/src/pages/home/report/ReportActionItemCreated.tsx +++ b/src/pages/home/report/ReportActionItemCreated.tsx @@ -35,7 +35,7 @@ type ReportActionItemCreatedProps = ReportActionItemCreatedOnyxProps & { /** The id of the policy */ // eslint-disable-next-line react/no-unused-prop-types - policyID: string; + policyID: string | undefined; }; function ReportActionItemCreated(props: ReportActionItemCreatedProps) { const styles = useThemeStyles(); diff --git a/src/pages/home/report/ReportActionItemMessageEdit.tsx b/src/pages/home/report/ReportActionItemMessageEdit.tsx index 2c9a4cbd21e8..fbf2da69aa31 100644 --- a/src/pages/home/report/ReportActionItemMessageEdit.tsx +++ b/src/pages/home/report/ReportActionItemMessageEdit.tsx @@ -5,6 +5,7 @@ import type {ForwardedRef} from 'react'; import React, {forwardRef, useCallback, useEffect, useMemo, useRef, useState} from 'react'; import {Keyboard, View} from 'react-native'; import type {NativeSyntheticEvent, TextInput, TextInputFocusEventData, TextInputKeyPressEventData} from 'react-native'; +import type {OnyxEntry} from 'react-native-onyx'; import type {Emoji} from '@assets/emojis/types'; import Composer from '@components/Composer'; import EmojiPickerButton from '@components/EmojiPicker/EmojiPickerButton'; @@ -58,7 +59,7 @@ type ReportActionItemMessageEditProps = { shouldDisableEmojiPicker?: boolean; /** Stores user's preferred skin tone */ - preferredSkinTone?: number; + preferredSkinTone?: OnyxEntry; }; // native ids @@ -69,7 +70,7 @@ const isMobileSafari = Browser.isMobileSafari(); function ReportActionItemMessageEdit( {action, draftMessage, reportID, index, shouldDisableEmojiPicker = false, preferredSkinTone = CONST.EMOJI_DEFAULT_SKIN_TONE}: ReportActionItemMessageEditProps, - forwardedRef: ForwardedRef, + forwardedRef: ForwardedRef<(TextInput & HTMLTextAreaElement) | undefined>, ) { const theme = useTheme(); const styles = useThemeStyles(); diff --git a/src/pages/home/report/ReportActionItemParentAction.tsx b/src/pages/home/report/ReportActionItemParentAction.tsx index af1c4e85104e..4a041fc495c0 100644 --- a/src/pages/home/report/ReportActionItemParentAction.tsx +++ b/src/pages/home/report/ReportActionItemParentAction.tsx @@ -83,7 +83,6 @@ function ReportActionItemParentAction({report, index = 0, shouldHideThreadDivide onClose={() => Report.navigateToConciergeChatAndDeleteReport(ancestor.report.reportID)} > Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(ancestor.report.reportID))} report={ancestor.report} action={ancestor.reportAction} diff --git a/src/pages/home/report/ReportActionItemSingle.tsx b/src/pages/home/report/ReportActionItemSingle.tsx index 741422cc7e82..696cd7a7d850 100644 --- a/src/pages/home/report/ReportActionItemSingle.tsx +++ b/src/pages/home/report/ReportActionItemSingle.tsx @@ -1,6 +1,7 @@ import React, {useCallback, useMemo} from 'react'; import type {StyleProp, ViewStyle} from 'react-native'; import {View} from 'react-native'; +import type {OnyxEntry} from 'react-native-onyx'; import Avatar from '@components/Avatar'; import MultipleAvatars from '@components/MultipleAvatars'; import OfflineWithFeedback from '@components/OfflineWithFeedback'; @@ -29,7 +30,7 @@ import ReportActionItemFragment from './ReportActionItemFragment'; type ReportActionItemSingleProps = Partial & { /** All the data of the action */ - action: ReportAction; + action: OnyxEntry; /** Styles for the outermost View */ wrapperStyle?: StyleProp; @@ -38,7 +39,7 @@ type ReportActionItemSingleProps = Partial & { report: Report; /** IOU Report for this action, if any */ - iouReport?: Report; + iouReport?: OnyxEntry; /** Show header for action */ showHeader?: boolean; @@ -77,12 +78,12 @@ function ReportActionItemSingle({ const StyleUtils = useStyleUtils(); const {translate} = useLocalize(); const personalDetails = usePersonalDetails() ?? CONST.EMPTY_OBJECT; - const actorAccountID = action.actionName === CONST.REPORT.ACTIONS.TYPE.REPORTPREVIEW && iouReport ? iouReport.managerID : action.actorAccountID; + const actorAccountID = action?.actionName === CONST.REPORT.ACTIONS.TYPE.REPORTPREVIEW && iouReport ? iouReport.managerID : action?.actorAccountID; let displayName = ReportUtils.getDisplayNameForParticipant(actorAccountID); const {avatar, login, pendingFields, status, fallbackIcon} = personalDetails[actorAccountID ?? -1] ?? {}; // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing let actorHint = (login || (displayName ?? '')).replace(CONST.REGEX.MERGED_ACCOUNT_PREFIX, ''); - const displayAllActors = useMemo(() => action.actionName === CONST.REPORT.ACTIONS.TYPE.REPORTPREVIEW && iouReport, [action.actionName, iouReport]); + const displayAllActors = useMemo(() => action?.actionName === CONST.REPORT.ACTIONS.TYPE.REPORTPREVIEW && iouReport, [action?.actionName, iouReport]); const isWorkspaceActor = ReportUtils.isPolicyExpenseChat(report) && (!actorAccountID || displayAllActors); let avatarSource = UserUtils.getAvatar(avatar ?? '', actorAccountID); @@ -90,7 +91,7 @@ function ReportActionItemSingle({ displayName = ReportUtils.getPolicyName(report); actorHint = displayName; avatarSource = ReportUtils.getWorkspaceAvatar(report); - } else if (action.delegateAccountID && personalDetails[action.delegateAccountID]) { + } else if (action?.delegateAccountID && personalDetails[action?.delegateAccountID]) { // We replace the actor's email, name, and avatar with the Copilot manually for now. And only if we have their // details. This will be improved upon when the Copilot feature is implemented. const delegateDetails = personalDetails[action.delegateAccountID]; @@ -141,7 +142,7 @@ function ReportActionItemSingle({ text: displayName, }, ] - : action.person; + : action?.person; const reportID = report?.reportID; const iouReportID = iouReport?.reportID; @@ -155,14 +156,14 @@ function ReportActionItemSingle({ Navigation.navigate(ROUTES.REPORT_PARTICIPANTS.getRoute(iouReportID)); return; } - showUserDetails(action.delegateAccountID ? String(action.delegateAccountID) : String(actorAccountID)); + showUserDetails(action?.delegateAccountID ? String(action.delegateAccountID) : String(actorAccountID)); } - }, [isWorkspaceActor, reportID, actorAccountID, action.delegateAccountID, iouReportID, displayAllActors]); + }, [isWorkspaceActor, reportID, actorAccountID, action?.delegateAccountID, iouReportID, displayAllActors]); const shouldDisableDetailPage = useMemo( () => CONST.RESTRICTED_ACCOUNT_IDS.includes(actorAccountID ?? 0) || - (!isWorkspaceActor && ReportUtils.isOptimisticPersonalDetail(action.delegateAccountID ? Number(action.delegateAccountID) : actorAccountID ?? -1)), + (!isWorkspaceActor && ReportUtils.isOptimisticPersonalDetail(action?.delegateAccountID ? Number(action.delegateAccountID) : actorAccountID ?? -1)), [action, isWorkspaceActor, actorAccountID], ); @@ -189,7 +190,7 @@ function ReportActionItemSingle({ return ( @@ -237,13 +238,13 @@ function ReportActionItemSingle({ {personArray?.map((fragment, index) => ( ))} @@ -255,7 +256,7 @@ function ReportActionItemSingle({ >{`${status?.emojiCode}`} )} - + ) : null} {children} diff --git a/src/pages/home/report/ReportActionItemThread.tsx b/src/pages/home/report/ReportActionItemThread.tsx index f7c7e5fcf91d..c0dbe2a3825d 100644 --- a/src/pages/home/report/ReportActionItemThread.tsx +++ b/src/pages/home/report/ReportActionItemThread.tsx @@ -1,4 +1,5 @@ import React from 'react'; +import type {GestureResponderEvent} from 'react-native'; import {View} from 'react-native'; import MultipleAvatars from '@components/MultipleAvatars'; import PressableWithSecondaryInteraction from '@components/PressableWithSecondaryInteraction'; @@ -26,7 +27,7 @@ type ReportActionItemThreadProps = { isHovered: boolean; /** The function that should be called when the thread is LongPressed or right-clicked */ - onSecondaryInteraction: () => void; + onSecondaryInteraction: (event: GestureResponderEvent | MouseEvent) => void; }; function ReportActionItemThread({numberOfReplies, icons, mostRecentReply, childReportID, isHovered, onSecondaryInteraction}: ReportActionItemThreadProps) { diff --git a/src/pages/iou/request/step/IOURequestStepTag.js b/src/pages/iou/request/step/IOURequestStepTag.js index af1de64f8930..1b53dab12fa3 100644 --- a/src/pages/iou/request/step/IOURequestStepTag.js +++ b/src/pages/iou/request/step/IOURequestStepTag.js @@ -1,5 +1,5 @@ import PropTypes from 'prop-types'; -import React from 'react'; +import React, {useMemo} from 'react'; import {withOnyx} from 'react-native-onyx'; import categoryPropTypes from '@components/categoryPropTypes'; import TagPicker from '@components/TagPicker'; @@ -11,7 +11,9 @@ import useThemeStyles from '@hooks/useThemeStyles'; import compose from '@libs/compose'; import * as IOUUtils from '@libs/IOUUtils'; import Navigation from '@libs/Navigation/Navigation'; +import * as OptionsListUtils from '@libs/OptionsListUtils'; import * as PolicyUtils from '@libs/PolicyUtils'; +import * as ReportUtils from '@libs/ReportUtils'; import {canEditMoneyRequest} from '@libs/ReportUtils'; import * as TransactionUtils from '@libs/TransactionUtils'; import reportActionPropTypes from '@pages/home/report/reportActionPropTypes'; @@ -78,10 +80,12 @@ function IOURequestStepTag({ const tag = TransactionUtils.getTag(transaction, tagIndex); const isEditing = action === CONST.IOU.ACTION.EDIT; const isSplitBill = iouType === CONST.IOU.TYPE.SPLIT; + const policyTagLists = useMemo(() => PolicyUtils.getTagLists(policyTags), [policyTags]); const parentReportAction = parentReportActions[report.parentReportActionID]; + const shouldShowTag = ReportUtils.isGroupPolicy(report) && (transactionTag || OptionsListUtils.hasEnabledTags(policyTagLists)); // eslint-disable-next-line rulesdir/no-negated-variables - const shouldShowNotFoundPage = isEditing && !canEditMoneyRequest(parentReportAction); + const shouldShowNotFoundPage = !shouldShowTag || (isEditing && !canEditMoneyRequest(parentReportAction)); const navigateBack = () => { Navigation.goBack(backTo); diff --git a/src/pages/settings/Wallet/WalletPage/WalletPage.tsx b/src/pages/settings/Wallet/WalletPage/WalletPage.tsx index cd8d5dc18d6b..88236e06f9a9 100644 --- a/src/pages/settings/Wallet/WalletPage/WalletPage.tsx +++ b/src/pages/settings/Wallet/WalletPage/WalletPage.tsx @@ -75,7 +75,7 @@ function WalletPage({bankAccountList = {}, cardList = {}, fundList = {}, isLoadi }); const addPaymentMethodAnchorRef = useRef(null); - const paymentMethodButtonRef = useRef(null); + const paymentMethodButtonRef = useRef(null); const [anchorPosition, setAnchorPosition] = useState({ anchorPositionHorizontal: 0, anchorPositionVertical: 0, @@ -164,7 +164,7 @@ function WalletPage({bankAccountList = {}, cardList = {}, fundList = {}, isLoadi setShouldShowDefaultDeleteMenu(false); return; } - paymentMethodButtonRef.current = nativeEvent?.currentTarget as HTMLElement; + paymentMethodButtonRef.current = nativeEvent?.currentTarget as HTMLDivElement; // The delete/default menu if (accountType) { diff --git a/src/pages/tasks/TaskAssigneeSelectorModal.js b/src/pages/tasks/TaskAssigneeSelectorModal.tsx similarity index 63% rename from src/pages/tasks/TaskAssigneeSelectorModal.js rename to src/pages/tasks/TaskAssigneeSelectorModal.tsx index d6b5d2e62a35..0ffb33b7590b 100644 --- a/src/pages/tasks/TaskAssigneeSelectorModal.js +++ b/src/pages/tasks/TaskAssigneeSelectorModal.tsx @@ -1,58 +1,49 @@ /* eslint-disable es/no-optional-chaining */ +import type {RouteProp} from '@react-navigation/native'; import {useRoute} from '@react-navigation/native'; -import lodashGet from 'lodash/get'; -import lodashPick from 'lodash/pick'; -import PropTypes from 'prop-types'; import React, {useCallback, useMemo, useState} from 'react'; import {View} from 'react-native'; import {withOnyx} from 'react-native-onyx'; +import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; import FullPageNotFoundView from '@components/BlockingViews/FullPageNotFoundView'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import {useBetas, usePersonalDetails, useSession} from '@components/OnyxProvider'; import ScreenWrapper from '@components/ScreenWrapper'; import SelectionList from '@components/SelectionList'; +import type {ListItem} from '@components/SelectionList/types'; import UserListItem from '@components/SelectionList/UserListItem'; +import withCurrentUserPersonalDetails from '@components/withCurrentUserPersonalDetails'; +import type {WithCurrentUserPersonalDetailsProps} from '@components/withCurrentUserPersonalDetails'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import useDebouncedState from '@hooks/useDebouncedState'; import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; -import compose from '@libs/compose'; import Navigation from '@libs/Navigation/Navigation'; import * as OptionsListUtils from '@libs/OptionsListUtils'; import * as ReportUtils from '@libs/ReportUtils'; -import reportPropTypes from '@pages/reportPropTypes'; -import * as Task from '@userActions/Task'; +import type {TaskDetailsNavigatorParamList} from '@navigation/types'; +import * as TaskActions from '@userActions/Task'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; +import type SCREENS from '@src/SCREENS'; +import type {Report, Task} from '@src/types/onyx'; -const propTypes = { +type TaskAssigneeSelectorModalOnyxProps = { /** All reports shared with the user */ - reports: PropTypes.objectOf(reportPropTypes), + reports: OnyxCollection; /** Grab the Share destination of the Task */ - task: PropTypes.shape({ - /** Share destination of the Task */ - shareDestination: PropTypes.string, - - /** The task report if it's currently being edited */ - report: reportPropTypes, - }), - - /** The policy of root parent report */ - rootParentReportPolicy: PropTypes.shape({ - /** The role of current user */ - role: PropTypes.string, - }), + task: OnyxEntry; }; -const defaultProps = { - reports: {}, - task: {}, - rootParentReportPolicy: {}, +type UseOptions = { + reports: OnyxCollection; }; -function useOptions({reports}) { +type TaskAssigneeSelectorModalProps = TaskAssigneeSelectorModalOnyxProps & WithCurrentUserPersonalDetailsProps; + +function useOptions({reports}: UseOptions) { const allPersonalDetails = usePersonalDetails() || CONST.EMPTY_OBJECT; const betas = useBetas(); const [isLoading, setIsLoading] = useState(true); @@ -78,7 +69,7 @@ function useOptions({reports}) { ); const headerMessage = OptionsListUtils.getHeaderMessage( - (recentReports.length || 0 + personalDetails.length || 0) !== 0 || currentUserOption, + (recentReports?.length || 0) + (personalDetails?.length || 0) !== 0 || Boolean(currentUserOption), Boolean(userToInvite), debouncedSearchValue, ); @@ -99,20 +90,20 @@ function useOptions({reports}) { return {...options, isLoading, searchValue, debouncedSearchValue, setSearchValue}; } -function TaskAssigneeSelectorModal({reports, task, rootParentReportPolicy}) { +function TaskAssigneeSelectorModal({reports, task}: TaskAssigneeSelectorModalProps) { const styles = useThemeStyles(); - const route = useRoute(); + const route = useRoute>(); const {translate} = useLocalize(); const session = useSession(); const currentUserPersonalDetails = useCurrentUserPersonalDetails(); - const {userToInvite, recentReports, personalDetails, currentUserOption, isLoading, searchValue, setSearchValue, headerMessage} = useOptions({reports, task}); + const {userToInvite, recentReports, personalDetails, currentUserOption, isLoading, searchValue, setSearchValue, headerMessage} = useOptions({reports}); const onChangeText = (newSearchTerm = '') => { setSearchValue(newSearchTerm); }; - const report = useMemo(() => { - if (!route.params || !route.params.reportID) { + const report: OnyxEntry = useMemo(() => { + if (!route.params?.reportID) { return null; } if (report && !ReportUtils.isTaskReport(report)) { @@ -120,7 +111,7 @@ function TaskAssigneeSelectorModal({reports, task, rootParentReportPolicy}) { Navigation.dismissModal(report.reportID); }); } - return reports[`${ONYXKEYS.COLLECTION.REPORT}${route.params.reportID}`]; + return reports?.[`${ONYXKEYS.COLLECTION.REPORT}${route.params?.reportID}`] ?? null; }, [reports, route]); const sections = useMemo(() => { @@ -155,17 +146,29 @@ function TaskAssigneeSelectorModal({reports, task, rootParentReportPolicy}) { if (userToInvite) { sectionsList.push({ + title: '', data: [userToInvite], shouldShow: true, indexOffset, }); } - return sectionsList; - }, [currentUserOption, personalDetails, recentReports, userToInvite, translate]); + return sectionsList.map((section) => ({ + ...section, + data: section.data.map((option) => ({ + ...option, + text: option.text ?? '', + alternateText: option.alternateText ?? undefined, + keyForList: option.keyForList ?? '', + isDisabled: option.isDisabled ?? undefined, + login: option.login ?? undefined, + shouldShowSubscript: option.shouldShowSubscript ?? undefined, + })), + })); + }, [currentUserOption, personalDetails, recentReports, translate, userToInvite]); const selectReport = useCallback( - (option) => { + (option: ListItem) => { if (!option) { return; } @@ -173,25 +176,35 @@ function TaskAssigneeSelectorModal({reports, task, rootParentReportPolicy}) { // Check to see if we're editing a task and if so, update the assignee if (report) { if (option.accountID !== report.managerID) { - const assigneeChatReport = Task.setAssigneeValue(option.login, option.accountID, report.reportID, OptionsListUtils.isCurrentUser(option)); + const assigneeChatReport = TaskActions.setAssigneeValue( + option?.login ?? '', + option?.accountID ?? -1, + report.reportID, + OptionsListUtils.isCurrentUser({...option, accountID: option?.accountID ?? -1, login: option?.login ?? ''}), + ); // Pass through the selected assignee - Task.editTaskAssignee(report, session.accountID, option.login, option.accountID, assigneeChatReport); + TaskActions.editTaskAssignee(report, session?.accountID ?? 0, option?.login ?? '', option?.accountID, assigneeChatReport); } Navigation.dismissModal(report.reportID); // If there's no report, we're creating a new task } else if (option.accountID) { - Task.setAssigneeValue(option.login, option.accountID, task.shareDestination, OptionsListUtils.isCurrentUser(option)); + TaskActions.setAssigneeValue( + option?.login ?? '', + option.accountID, + task?.shareDestination ?? '', + OptionsListUtils.isCurrentUser({...option, accountID: option?.accountID ?? -1, login: option?.login ?? undefined}), + ); Navigation.goBack(ROUTES.NEW_TASK); } }, - [session.accountID, task.shareDestination, report], + [session?.accountID, task?.shareDestination, report], ); - const handleBackButtonPress = useCallback(() => (lodashGet(route.params, 'reportID') ? Navigation.dismissModal() : Navigation.goBack(ROUTES.NEW_TASK)), [route.params]); + const handleBackButtonPress = useCallback(() => (route.params?.reportID ? Navigation.dismissModal() : Navigation.goBack(ROUTES.NEW_TASK)), [route.params]); const isOpen = ReportUtils.isOpenTaskReport(report); - const canModifyTask = Task.canModifyTask(report, currentUserPersonalDetails.accountID, lodashGet(rootParentReportPolicy, 'role', '')); + const canModifyTask = TaskActions.canModifyTask(report, currentUserPersonalDetails.accountID); const isTaskNonEditable = ReportUtils.isTaskReport(report) && (!canModifyTask || !isOpen); return ( @@ -199,7 +212,7 @@ function TaskAssigneeSelectorModal({reports, task, rootParentReportPolicy}) { includeSafeAreaPaddingBottom={false} testID={TaskAssigneeSelectorModal.displayName} > - {({didScreenTransitionEnd, safeAreaPaddingBottomStyle}) => ( + {({didScreenTransitionEnd}) => ( @@ -225,26 +237,14 @@ function TaskAssigneeSelectorModal({reports, task, rootParentReportPolicy}) { } TaskAssigneeSelectorModal.displayName = 'TaskAssigneeSelectorModal'; -TaskAssigneeSelectorModal.propTypes = propTypes; -TaskAssigneeSelectorModal.defaultProps = defaultProps; -export default compose( - withOnyx({ - reports: { - key: ONYXKEYS.COLLECTION.REPORT, - }, - task: { - key: ONYXKEYS.TASK, - }, - }), - withOnyx({ - rootParentReportPolicy: { - key: ({reports, route}) => { - const report = reports[`${ONYXKEYS.COLLECTION.REPORT}${route.params?.reportID || '0'}`]; - const rootParentReport = ReportUtils.getRootParentReport(report); - return `${ONYXKEYS.COLLECTION.POLICY}${rootParentReport ? rootParentReport.policyID : '0'}`; - }, - selector: (policy) => lodashPick(policy, ['role']), - }, - }), -)(TaskAssigneeSelectorModal); +const TaskAssigneeSelectorModalWithOnyx = withOnyx({ + reports: { + key: ONYXKEYS.COLLECTION.REPORT, + }, + task: { + key: ONYXKEYS.TASK, + }, +})(TaskAssigneeSelectorModal); + +export default withCurrentUserPersonalDetails(TaskAssigneeSelectorModalWithOnyx); diff --git a/src/pages/tasks/TaskDescriptionPage.js b/src/pages/tasks/TaskDescriptionPage.tsx similarity index 61% rename from src/pages/tasks/TaskDescriptionPage.js rename to src/pages/tasks/TaskDescriptionPage.tsx index b8b48abd09ff..e08d6380bb18 100644 --- a/src/pages/tasks/TaskDescriptionPage.js +++ b/src/pages/tasks/TaskDescriptionPage.tsx @@ -2,53 +2,43 @@ import {useFocusEffect} from '@react-navigation/native'; import ExpensiMark from 'expensify-common/lib/ExpensiMark'; import React, {useCallback, useRef} from 'react'; import {View} from 'react-native'; -import {withOnyx} from 'react-native-onyx'; import FullPageNotFoundView from '@components/BlockingViews/FullPageNotFoundView'; import FormProvider from '@components/Form/FormProvider'; import InputWrapper from '@components/Form/InputWrapper'; +import type {FormInputErrors, FormOnyxValues} from '@components/Form/types'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; +import type {AnimatedTextInputRef} from '@components/RNTextInput'; import ScreenWrapper from '@components/ScreenWrapper'; import TextInput from '@components/TextInput'; import withCurrentUserPersonalDetails from '@components/withCurrentUserPersonalDetails'; -import withLocalize, {withLocalizePropTypes} from '@components/withLocalize'; +import type {WithCurrentUserPersonalDetailsProps} from '@components/withCurrentUserPersonalDetails'; +import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; -import compose from '@libs/compose'; import * as ErrorUtils from '@libs/ErrorUtils'; import Navigation from '@libs/Navigation/Navigation'; import * as ReportUtils from '@libs/ReportUtils'; import StringUtils from '@libs/StringUtils'; import updateMultilineInputRange from '@libs/updateMultilineInputRange'; import withReportOrNotFound from '@pages/home/report/withReportOrNotFound'; -import reportPropTypes from '@pages/reportPropTypes'; +import type {WithReportOrNotFoundProps} from '@pages/home/report/withReportOrNotFound'; import * as Task from '@userActions/Task'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import INPUT_IDS from '@src/types/form/EditTaskForm'; +import {isEmptyObject} from '@src/types/utils/EmptyObject'; -const propTypes = { - /** The report currently being looked at */ - report: reportPropTypes, - - /* Onyx Props */ - ...withLocalizePropTypes, -}; - -const defaultProps = { - report: {}, -}; +type TaskDescriptionPageProps = WithReportOrNotFoundProps & WithCurrentUserPersonalDetailsProps; const parser = new ExpensiMark(); -function TaskDescriptionPage(props) { + +function TaskDescriptionPage({report, currentUserPersonalDetails}: TaskDescriptionPageProps) { const styles = useThemeStyles(); + const {translate} = useLocalize(); - /** - * @param {Object} values - form input values passed by the Form component - * @returns {Boolean} - */ - const validate = useCallback((values) => { + const validate = useCallback((values: FormOnyxValues): FormInputErrors => { const errors = {}; - if (values.description.length > CONST.DESCRIPTION_LIMIT) { + if (values?.description && values.description?.length > CONST.DESCRIPTION_LIMIT) { ErrorUtils.addErrorMessage(errors, 'description', ['common.error.characterLimitExceedCounter', {length: values.description.length, limit: CONST.DESCRIPTION_LIMIT}]); } @@ -56,30 +46,30 @@ function TaskDescriptionPage(props) { }, []); const submit = useCallback( - (values) => { - // props.report.description might contain CRLF from the server - if (StringUtils.normalizeCRLF(values.description) !== StringUtils.normalizeCRLF(props.report.description)) { + (values: FormOnyxValues) => { + // report.description might contain CRLF from the server + if (StringUtils.normalizeCRLF(values.description) !== StringUtils.normalizeCRLF(report?.description) && !isEmptyObject(report)) { // Set the description of the report in the store and then call EditTask API // to update the description of the report on the server - Task.editTask(props.report, {description: values.description}); + Task.editTask(report, {description: values.description}); } - Navigation.dismissModal(props.report.reportID); + Navigation.dismissModal(report?.reportID); }, - [props], + [report], ); - if (!ReportUtils.isTaskReport(props.report)) { + if (!ReportUtils.isTaskReport(report)) { Navigation.isNavigationReady().then(() => { - Navigation.dismissModal(props.report.reportID); + Navigation.dismissModal(report?.reportID); }); } - const inputRef = useRef(null); - const focusTimeoutRef = useRef(null); + const inputRef = useRef(null); + const focusTimeoutRef = useRef(null); - const isOpen = ReportUtils.isOpenTaskReport(props.report); - const canModifyTask = Task.canModifyTask(props.report, props.currentUserPersonalDetails.accountID); - const isTaskNonEditable = ReportUtils.isTaskReport(props.report) && (!canModifyTask || !isOpen); + const isOpen = ReportUtils.isOpenTaskReport(report); + const canModifyTask = Task.canModifyTask(report, currentUserPersonalDetails.accountID); + const isTaskNonEditable = ReportUtils.isTaskReport(report) && (!canModifyTask || !isOpen); useFocusEffect( useCallback(() => { @@ -104,13 +94,13 @@ function TaskDescriptionPage(props) { testID={TaskDescriptionPage.displayName} > - + @@ -119,14 +109,14 @@ function TaskDescriptionPage(props) { role={CONST.ROLE.PRESENTATION} inputID={INPUT_IDS.DESCRIPTION} name={INPUT_IDS.DESCRIPTION} - label={props.translate('newTaskPage.descriptionOptional')} - accessibilityLabel={props.translate('newTaskPage.descriptionOptional')} - defaultValue={parser.htmlToMarkdown((props.report && parser.replace(props.report.description)) || '')} - ref={(el) => { - if (!el) { + label={translate('newTaskPage.descriptionOptional')} + accessibilityLabel={translate('newTaskPage.descriptionOptional')} + defaultValue={parser.htmlToMarkdown((report && parser.replace(report?.description ?? '')) || '')} + ref={(element: AnimatedTextInputRef) => { + if (!element) { return; } - inputRef.current = el; + inputRef.current = element; updateMultilineInputRange(inputRef.current); }} autoGrowHeight @@ -140,17 +130,8 @@ function TaskDescriptionPage(props) { ); } -TaskDescriptionPage.propTypes = propTypes; -TaskDescriptionPage.defaultProps = defaultProps; TaskDescriptionPage.displayName = 'TaskDescriptionPage'; -export default compose( - withLocalize, - withCurrentUserPersonalDetails, - withReportOrNotFound(), - withOnyx({ - report: { - key: ({route}) => `${ONYXKEYS.COLLECTION.REPORT}${route.params.reportID}`, - }, - }), -)(TaskDescriptionPage); +const ComponentWithCurrentUserPersonalDetails = withCurrentUserPersonalDetails(TaskDescriptionPage); + +export default withReportOrNotFound()(ComponentWithCurrentUserPersonalDetails); diff --git a/src/pages/tasks/TaskShareDestinationSelectorModal.js b/src/pages/tasks/TaskShareDestinationSelectorModal.tsx similarity index 62% rename from src/pages/tasks/TaskShareDestinationSelectorModal.js rename to src/pages/tasks/TaskShareDestinationSelectorModal.tsx index b62440b22967..5b56e58752ac 100644 --- a/src/pages/tasks/TaskShareDestinationSelectorModal.js +++ b/src/pages/tasks/TaskShareDestinationSelectorModal.tsx @@ -1,9 +1,7 @@ -import keys from 'lodash/keys'; -import reduce from 'lodash/reduce'; -import PropTypes from 'prop-types'; import React, {useEffect, useMemo} from 'react'; import {View} from 'react-native'; import {withOnyx} from 'react-native-onyx'; +import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import {usePersonalDetails} from '@components/OnyxProvider'; import ScreenWrapper from '@components/ScreenWrapper'; @@ -13,51 +11,45 @@ import useDebouncedState from '@hooks/useDebouncedState'; import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; import useThemeStyles from '@hooks/useThemeStyles'; -import * as Report from '@libs/actions/Report'; +import * as ReportActions from '@libs/actions/Report'; import Navigation from '@libs/Navigation/Navigation'; import * as OptionsListUtils from '@libs/OptionsListUtils'; import * as ReportUtils from '@libs/ReportUtils'; -import reportPropTypes from '@pages/reportPropTypes'; import * as Task from '@userActions/Task'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; +import type {Report} from '@src/types/onyx'; -const propTypes = { - /** All reports shared with the user */ - reports: PropTypes.objectOf(reportPropTypes), - /** Whether or not we are searching for reports on the server */ - isSearchingForReports: PropTypes.bool, -}; +type TaskShareDestinationSelectorModalOnyxProps = { + reports: OnyxCollection; -const defaultProps = { - reports: {}, - isSearchingForReports: false, + isSearchingForReports: OnyxEntry; }; -const selectReportHandler = (option) => { - if (!option || !option.reportID) { +type TaskShareDestinationSelectorModalProps = TaskShareDestinationSelectorModalOnyxProps; + +const selectReportHandler = (option: unknown) => { + const optionItem = option as ReportUtils.OptionData; + + if (!optionItem || !optionItem?.reportID) { return; } - Task.setShareDestinationValue(option.reportID); + Task.setShareDestinationValue(optionItem?.reportID); Navigation.goBack(ROUTES.NEW_TASK); }; -const reportFilter = (reports) => - reduce( - keys(reports), - (filtered, reportKey) => { - const report = reports[reportKey]; - if (ReportUtils.canUserPerformWriteAction(report) && ReportUtils.canCreateTaskInReport(report) && !ReportUtils.isCanceledTaskReport(report)) { - return {...filtered, [reportKey]: report}; - } - return filtered; - }, - {}, - ); +const reportFilter = (reports: OnyxCollection) => + Object.keys(reports ?? {}).reduce((filtered, reportKey) => { + const report: OnyxEntry = reports?.[reportKey] ?? null; + if (ReportUtils.canUserPerformWriteAction(report) && ReportUtils.canCreateTaskInReport(report) && !ReportUtils.isCanceledTaskReport(report)) { + return {...filtered, [reportKey]: report}; + } + return filtered; + }, {}); -function TaskShareDestinationSelectorModal({reports, isSearchingForReports}) { +function TaskShareDestinationSelectorModal({reports, isSearchingForReports}: TaskShareDestinationSelectorModalProps) { const styles = useThemeStyles(); const [searchValue, debouncedSearchValue, setSearchValue] = useDebouncedState(''); const {translate} = useLocalize(); @@ -73,13 +65,29 @@ function TaskShareDestinationSelectorModal({reports, isSearchingForReports}) { const headerMessage = OptionsListUtils.getHeaderMessage(recentReports && recentReports.length !== 0, false, debouncedSearchValue); - const sections = recentReports && recentReports.length > 0 ? [{data: recentReports, shouldShow: true}] : []; + const sections = + recentReports && recentReports.length > 0 + ? [ + { + data: recentReports.map((option) => ({ + ...option, + text: option.text ?? '', + alternateText: option.alternateText ?? undefined, + keyForList: option.keyForList ?? '', + isDisabled: option.isDisabled ?? undefined, + login: option.login ?? undefined, + shouldShowSubscript: option.shouldShowSubscript ?? undefined, + })), + shouldShow: true, + }, + ] + : []; return {sections, headerMessage}; }, [personalDetails, reports, debouncedSearchValue]); useEffect(() => { - Report.searchInServer(debouncedSearchValue); + ReportActions.searchInServer(debouncedSearchValue); }, [debouncedSearchValue]); return ( @@ -87,7 +95,7 @@ function TaskShareDestinationSelectorModal({reports, isSearchingForReports}) { includeSafeAreaPaddingBottom={false} testID="TaskShareDestinationSelectorModal" > - {({didScreenTransitionEnd, safeAreaPaddingBottomStyle}) => ( + {({didScreenTransitionEnd}) => ( <> @@ -115,10 +122,8 @@ function TaskShareDestinationSelectorModal({reports, isSearchingForReports}) { } TaskShareDestinationSelectorModal.displayName = 'TaskShareDestinationSelectorModal'; -TaskShareDestinationSelectorModal.propTypes = propTypes; -TaskShareDestinationSelectorModal.defaultProps = defaultProps; -export default withOnyx({ +export default withOnyx({ reports: { key: ONYXKEYS.COLLECTION.REPORT, }, diff --git a/src/pages/tasks/TaskTitlePage.js b/src/pages/tasks/TaskTitlePage.tsx similarity index 50% rename from src/pages/tasks/TaskTitlePage.js rename to src/pages/tasks/TaskTitlePage.tsx index 370baab7cd89..009983beac3e 100644 --- a/src/pages/tasks/TaskTitlePage.js +++ b/src/pages/tasks/TaskTitlePage.tsx @@ -1,98 +1,85 @@ import React, {useCallback, useRef} from 'react'; import {View} from 'react-native'; -import {withOnyx} from 'react-native-onyx'; -import _ from 'underscore'; import FullPageNotFoundView from '@components/BlockingViews/FullPageNotFoundView'; import FormProvider from '@components/Form/FormProvider'; import InputWrapper from '@components/Form/InputWrapper'; +import type {FormInputErrors, FormOnyxValues} from '@components/Form/types'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; +import type {AnimatedTextInputRef} from '@components/RNTextInput'; import ScreenWrapper from '@components/ScreenWrapper'; import TextInput from '@components/TextInput'; import withCurrentUserPersonalDetails from '@components/withCurrentUserPersonalDetails'; -import withLocalize, {withLocalizePropTypes} from '@components/withLocalize'; +import type {WithCurrentUserPersonalDetailsProps} from '@components/withCurrentUserPersonalDetails'; +import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; -import compose from '@libs/compose'; -import * as ErrorUtils from '@libs/ErrorUtils'; import Navigation from '@libs/Navigation/Navigation'; import * as ReportUtils from '@libs/ReportUtils'; import withReportOrNotFound from '@pages/home/report/withReportOrNotFound'; -import reportPropTypes from '@pages/reportPropTypes'; +import type {WithReportOrNotFoundProps} from '@pages/home/report/withReportOrNotFound'; import * as Task from '@userActions/Task'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import INPUT_IDS from '@src/types/form/EditTaskForm'; +import {isEmptyObject} from '@src/types/utils/EmptyObject'; -const propTypes = { - /** The report currently being looked at */ - report: reportPropTypes, +type TaskTitlePageProps = WithReportOrNotFoundProps & WithCurrentUserPersonalDetailsProps; - /* Onyx Props */ - ...withLocalizePropTypes, -}; - -const defaultProps = { - report: {}, -}; - -function TaskTitlePage(props) { +function TaskTitlePage({report, currentUserPersonalDetails}: TaskTitlePageProps) { const styles = useThemeStyles(); - /** - * @param {Object} values - * @param {String} values.title - * @returns {Object} - An object containing the errors for each inputID - */ - const validate = useCallback((values) => { - const errors = {}; + const {translate} = useLocalize(); + + const validate = useCallback(({title}: FormOnyxValues): FormInputErrors => { + const errors: FormInputErrors = {}; - if (_.isEmpty(values.title)) { + if (!title) { errors.title = 'newTaskPage.pleaseEnterTaskName'; - } else if (values.title.length > CONST.TITLE_CHARACTER_LIMIT) { - ErrorUtils.addErrorMessage(errors, 'title', ['common.error.characterLimitExceedCounter', {length: values.title.length, limit: CONST.TITLE_CHARACTER_LIMIT}]); } return errors; }, []); const submit = useCallback( - (values) => { - if (values.title !== props.report.reportName) { + (values: FormOnyxValues) => { + if (values.title !== report?.reportName && !isEmptyObject(report)) { // Set the title of the report in the store and then call EditTask API // to update the title of the report on the server - Task.editTask(props.report, {title: values.title}); + Task.editTask(report, {title: values.title}); } - Navigation.dismissModal(props.report.reportID); + Navigation.dismissModal(report?.reportID); }, - [props], + [report], ); - if (!ReportUtils.isTaskReport(props.report)) { + if (!ReportUtils.isTaskReport(report)) { Navigation.isNavigationReady().then(() => { - Navigation.dismissModal(props.report.reportID); + Navigation.dismissModal(report?.reportID); }); } - const inputRef = useRef(null); - const isOpen = ReportUtils.isOpenTaskReport(props.report); - const canModifyTask = Task.canModifyTask(props.report, props.currentUserPersonalDetails.accountID); - const isTaskNonEditable = ReportUtils.isTaskReport(props.report) && (!canModifyTask || !isOpen); + const inputRef = useRef(null); + const isOpen = ReportUtils.isOpenTaskReport(report); + const canModifyTask = Task.canModifyTask(report, currentUserPersonalDetails.accountID); + const isTaskNonEditable = ReportUtils.isTaskReport(report) && (!canModifyTask || !isOpen); return ( inputRef.current && inputRef.current.focus()} + onEntryTransitionEnd={() => { + inputRef?.current?.focus(); + }} shouldEnableMaxHeight testID={TaskTitlePage.displayName} > {({didScreenTransitionEnd}) => ( - + @@ -101,17 +88,17 @@ function TaskTitlePage(props) { role={CONST.ROLE.PRESENTATION} inputID={INPUT_IDS.TITLE} name={INPUT_IDS.TITLE} - label={props.translate('task.title')} - accessibilityLabel={props.translate('task.title')} - defaultValue={(props.report && props.report.reportName) || ''} - ref={(el) => { - if (!el) { + label={translate('task.title')} + accessibilityLabel={translate('task.title')} + defaultValue={report?.reportName ?? ''} + ref={(element: AnimatedTextInputRef) => { + if (!element) { return; } if (!inputRef.current && didScreenTransitionEnd) { - el.focus(); + element.focus(); } - inputRef.current = el; + inputRef.current = element; }} /> @@ -122,17 +109,8 @@ function TaskTitlePage(props) { ); } -TaskTitlePage.propTypes = propTypes; -TaskTitlePage.defaultProps = defaultProps; TaskTitlePage.displayName = 'TaskTitlePage'; -export default compose( - withLocalize, - withCurrentUserPersonalDetails, - withReportOrNotFound(), - withOnyx({ - report: { - key: ({route}) => `${ONYXKEYS.COLLECTION.REPORT}${route.params.reportID}`, - }, - }), -)(TaskTitlePage); +const ComponentWithCurrentUserPersonalDetails = withCurrentUserPersonalDetails(TaskTitlePage); + +export default withReportOrNotFound()(ComponentWithCurrentUserPersonalDetails); diff --git a/src/pages/workspace/WorkspaceMembersPage.tsx b/src/pages/workspace/WorkspaceMembersPage.tsx index 100044344d4e..42f29f885c00 100644 --- a/src/pages/workspace/WorkspaceMembersPage.tsx +++ b/src/pages/workspace/WorkspaceMembersPage.tsx @@ -254,6 +254,23 @@ function WorkspaceMembersPage({policyMembers, personalDetails, route, policy, se [selectedEmployees, addUser, removeUser], ); + /** Opens the member details page */ + const openMemberDetails = useCallback( + (item: MemberOption) => { + if (!isPolicyAdmin) { + Navigation.navigate(ROUTES.PROFILE.getRoute(item.accountID)); + return; + } + + if (!PolicyUtils.isPaidGroupPolicy(policy)) { + return; + } + + Navigation.navigate(ROUTES.WORKSPACE_MEMBER_DETAILS.getRoute(route.params.policyID, item.accountID, Navigation.getActiveRoute())); + }, + [isPolicyAdmin, policy, route.params.policyID], + ); + /** * Dismisses the errors on one item */ @@ -465,7 +482,7 @@ function WorkspaceMembersPage({policyMembers, personalDetails, route, policy, se onPress={inviteUser} text={translate('workspace.invite.member')} icon={Expensicons.Plus} - iconStyles={{transform: [{scale: 0.6}]}} + iconStyles={StyleUtils.getTransformScaleStyle(0.6)} innerStyles={[isSmallScreenWidth && styles.alignItemsCenter]} style={[isSmallScreenWidth && styles.flexGrow1]} /> @@ -525,13 +542,8 @@ function WorkspaceMembersPage({policyMembers, personalDetails, route, policy, se disableKeyboardShortcuts={removeMembersConfirmModalVisible} headerMessage={getHeaderMessage()} headerContent={getHeaderContent()} - onSelectRow={(item) => { - if (!isPolicyAdmin) { - Navigation.navigate(ROUTES.PROFILE.getRoute(item.accountID)); - return; - } - toggleUser(item.accountID); - }} + onSelectRow={openMemberDetails} + onCheckboxPress={(item) => toggleUser(item.accountID)} onSelectAll={() => toggleAllUsers(data)} onDismissError={dismissError} showLoadingPlaceholder={!isOfflineAndNoMemberDataAvailable && (!OptionsListUtils.isPersonalDetailsReady(personalDetails) || isEmptyObject(policyMembers))} diff --git a/src/pages/workspace/members/WorkspaceMemberDetailsPage.tsx b/src/pages/workspace/members/WorkspaceMemberDetailsPage.tsx new file mode 100644 index 000000000000..d44ff8baa08b --- /dev/null +++ b/src/pages/workspace/members/WorkspaceMemberDetailsPage.tsx @@ -0,0 +1,152 @@ +import type {StackScreenProps} from '@react-navigation/stack'; +import React, {useCallback} from 'react'; +import {View} from 'react-native'; +import type {OnyxEntry} from 'react-native-onyx'; +import {withOnyx} from 'react-native-onyx'; +import Avatar from '@components/Avatar'; +import Button from '@components/Button'; +import ConfirmModal from '@components/ConfirmModal'; +import HeaderWithBackButton from '@components/HeaderWithBackButton'; +import * as Expensicons from '@components/Icon/Expensicons'; +import MenuItem from '@components/MenuItem'; +import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; +import OfflineWithFeedback from '@components/OfflineWithFeedback'; +import ScreenWrapper from '@components/ScreenWrapper'; +import Text from '@components/Text'; +import useLocalize from '@hooks/useLocalize'; +import useStyleUtils from '@hooks/useStyleUtils'; +import useThemeStyles from '@hooks/useThemeStyles'; +import * as UserUtils from '@libs/UserUtils'; +import Navigation from '@navigation/Navigation'; +import type {SettingsNavigatorParamList} from '@navigation/types'; +import AdminPolicyAccessOrNotFoundWrapper from '@pages/workspace/AdminPolicyAccessOrNotFoundWrapper'; +import PaidPolicyAccessOrNotFoundWrapper from '@pages/workspace/PaidPolicyAccessOrNotFoundWrapper'; +import type {WithPolicyAndFullscreenLoadingProps} from '@pages/workspace/withPolicyAndFullscreenLoading'; +import withPolicyAndFullscreenLoading from '@pages/workspace/withPolicyAndFullscreenLoading'; +import * as Policy from '@userActions/Policy'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {Route} from '@src/ROUTES'; +import ROUTES from '@src/ROUTES'; +import type SCREENS from '@src/SCREENS'; +import type {PersonalDetails, PersonalDetailsList} from '@src/types/onyx'; + +type WorkspacePolicyOnyxProps = { + /** Personal details of all users */ + personalDetails: OnyxEntry; +}; + +type WorkspaceMemberDetailsPageProps = WithPolicyAndFullscreenLoadingProps & WorkspacePolicyOnyxProps & StackScreenProps; + +function WorkspaceMemberDetailsPage({personalDetails, policyMembers, policy, route}: WorkspaceMemberDetailsPageProps) { + const styles = useThemeStyles(); + const {translate} = useLocalize(); + const StyleUtils = useStyleUtils(); + + const [isRemoveMemberConfirmModalVisible, setIsRemoveMemberConfirmModalVisible] = React.useState(false); + + const accountID = Number(route.params.accountID); + const policyID = route.params.policyID; + const backTo = route.params.backTo ?? ('' as Route); + + const member = policyMembers?.[accountID]; + const details = personalDetails?.[accountID] ?? ({} as PersonalDetails); + const avatar = details.avatar ?? UserUtils.getDefaultAvatar(); + const fallbackIcon = details.fallbackIcon ?? ''; + const displayName = details.displayName ?? ''; + + const askForConfirmationToRemove = () => { + setIsRemoveMemberConfirmModalVisible(true); + }; + + const removeUser = useCallback(() => { + Policy.removeMembers([accountID], route.params.policyID); + setIsRemoveMemberConfirmModalVisible(false); + Navigation.goBack(backTo); + }, [accountID, backTo, route.params.policyID]); + + const navigateToProfile = useCallback(() => { + Navigation.navigate(ROUTES.PROFILE.getRoute(accountID, Navigation.getActiveRoute())); + }, [accountID]); + + const openRoleSelectionModal = useCallback(() => { + Navigation.navigate(ROUTES.WORKSPACE_MEMBER_ROLE_SELECTION.getRoute(route.params.policyID, accountID, Navigation.getActiveRoute())); + }, [accountID, route.params.policyID]); + + return ( + + + + Navigation.goBack(backTo)} + /> + + + + + + {Boolean(details.displayName ?? '') && ( + + {displayName} + + )} +