diff --git a/.storybook/webpack.config.js b/.storybook/webpack.config.js index 6717c1736f65..204f70344b18 100644 --- a/.storybook/webpack.config.js +++ b/.storybook/webpack.config.js @@ -25,7 +25,7 @@ module.exports = ({config}) => { config.resolve.alias = { 'react-native-config': 'react-web-config', 'react-native$': 'react-native-web', - '@react-native-community/netinfo': path.resolve(__dirname, '../__mocks__/@react-native-community/netinfo.js'), + '@react-native-community/netinfo': path.resolve(__dirname, '../__mocks__/@react-native-community/netinfo.ts'), '@react-navigation/native': path.resolve(__dirname, '../__mocks__/@react-navigation/native'), // Module alias support for storybook files, coping from `webpack.common.js` diff --git a/__mocks__/@react-native-community/netinfo.js b/__mocks__/@react-native-community/netinfo.js deleted file mode 100644 index 53a9282ea8db..000000000000 --- a/__mocks__/@react-native-community/netinfo.js +++ /dev/null @@ -1,19 +0,0 @@ -const defaultState = { - type: 'cellular', - isConnected: true, - isInternetReachable: true, - details: { - isConnectionExpensive: true, - cellularGeneration: '3g', - }, -}; - -const RNCNetInfoMock = { - configure: () => {}, - fetch: () => Promise.resolve(defaultState), - refresh: () => Promise.resolve(defaultState), - addEventListener: () => () => {}, - useNetInfo: () => {}, -}; - -export default RNCNetInfoMock; diff --git a/__mocks__/@react-native-community/netinfo.ts b/__mocks__/@react-native-community/netinfo.ts new file mode 100644 index 000000000000..0b7bdc9010a3 --- /dev/null +++ b/__mocks__/@react-native-community/netinfo.ts @@ -0,0 +1,31 @@ +import {NetInfoCellularGeneration, NetInfoStateType} from '@react-native-community/netinfo'; +import type {addEventListener, configure, fetch, NetInfoState, refresh, useNetInfo} from '@react-native-community/netinfo'; + +const defaultState: NetInfoState = { + type: NetInfoStateType.cellular, + isConnected: true, + isInternetReachable: true, + details: { + isConnectionExpensive: true, + cellularGeneration: NetInfoCellularGeneration['3g'], + carrier: 'T-Mobile', + }, +}; + +type NetInfoMock = { + configure: typeof configure; + fetch: typeof fetch; + refresh: typeof refresh; + addEventListener: typeof addEventListener; + useNetInfo: typeof useNetInfo; +}; + +const netInfoMock: NetInfoMock = { + configure: () => {}, + fetch: () => Promise.resolve(defaultState), + refresh: () => Promise.resolve(defaultState), + addEventListener: () => () => {}, + useNetInfo: () => defaultState, +}; + +export default netInfoMock; diff --git a/src/CONST.ts b/src/CONST.ts index 73375043bc50..aec496973c52 100755 --- a/src/CONST.ts +++ b/src/CONST.ts @@ -189,6 +189,7 @@ const CONST = { UNIX_EPOCH: '1970-01-01 00:00:00.000', MAX_DATE: '9999-12-31', MIN_DATE: '0001-01-01', + ORDINAL_DAY_OF_MONTH: 'do', }, SMS: { DOMAIN: '@expensify.sms', diff --git a/src/components/Form/FormProvider.tsx b/src/components/Form/FormProvider.tsx index 424fd989291a..ba0f823fdbad 100644 --- a/src/components/Form/FormProvider.tsx +++ b/src/components/Form/FormProvider.tsx @@ -1,6 +1,7 @@ import lodashIsEqual from 'lodash/isEqual'; import type {ForwardedRef, MutableRefObject, ReactNode} from 'react'; import React, {createRef, forwardRef, useCallback, useImperativeHandle, useMemo, useRef, useState} from 'react'; +import type {NativeSyntheticEvent, TextInputSubmitEditingEventData} from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; import {withOnyx} from 'react-native-onyx'; import * as ValidationUtils from '@libs/ValidationUtils'; @@ -204,7 +205,7 @@ function FormProvider( })); const registerInput = useCallback( - (inputID: keyof Form, inputProps: TInputProps): TInputProps => { + (inputID: keyof Form, shouldSubmitForm: boolean, inputProps: TInputProps): TInputProps => { const newRef: MutableRefObject = inputRefs.current[inputID] ?? inputProps.ref ?? createRef(); if (inputRefs.current[inputID] !== newRef) { inputRefs.current[inputID] = newRef; @@ -232,6 +233,14 @@ function FormProvider( return { ...inputProps, + ...(shouldSubmitForm && { + onSubmitEditing: (event: NativeSyntheticEvent) => { + submit(); + + inputProps.onSubmitEditing?.(event); + }, + returnKeyType: 'go', + }), ref: typeof inputRef === 'function' ? (node: BaseInputProps) => { @@ -319,7 +328,7 @@ function FormProvider( }, }; }, - [draftValues, formID, errors, formState, hasServerError, inputValues, onValidate, setTouchedInput, shouldValidateOnBlur, shouldValidateOnChange], + [draftValues, inputValues, formState?.errorFields, errors, submit, setTouchedInput, shouldValidateOnBlur, onValidate, hasServerError, formID, shouldValidateOnChange], ); const value = useMemo(() => ({registerInput}), [registerInput]); diff --git a/src/components/Form/FormWrapper.tsx b/src/components/Form/FormWrapper.tsx index aa3aa5b40b5a..074069ec3ea7 100644 --- a/src/components/Form/FormWrapper.tsx +++ b/src/components/Form/FormWrapper.tsx @@ -5,7 +5,7 @@ import {Keyboard, ScrollView} from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; import {withOnyx} from 'react-native-onyx'; import FormAlertWithSubmitButton from '@components/FormAlertWithSubmitButton'; -import FormSubmit from '@components/FormSubmit'; +import FormElement from '@components/FormElement'; import SafeAreaConsumer from '@components/SafeAreaConsumer'; import type {SafeAreaChildrenProps} from '@components/SafeAreaConsumer/types'; import ScrollViewWithContext from '@components/ScrollViewWithContext'; @@ -94,11 +94,10 @@ function FormWrapper({ const scrollViewContent = useCallback( (safeAreaPaddingBottomStyle: SafeAreaChildrenProps['safeAreaPaddingBottomStyle']) => ( - {children} {isSubmitButtonVisible && ( @@ -116,7 +115,7 @@ function FormWrapper({ disablePressOnEnter={disablePressOnEnter} /> )} - + ), [ children, diff --git a/src/components/Form/InputWrapper.tsx b/src/components/Form/InputWrapper.tsx index ae78e909753b..fc9d1773c5d8 100644 --- a/src/components/Form/InputWrapper.tsx +++ b/src/components/Form/InputWrapper.tsx @@ -1,21 +1,66 @@ import type {ForwardedRef} from 'react'; import React, {forwardRef, useContext} from 'react'; import type {AnimatedTextInputRef} from '@components/RNTextInput'; +import RoomNameInput from '@components/RoomNameInput'; import TextInput from '@components/TextInput'; +import {canUseTouchScreen} from '@libs/DeviceCapabilities'; import FormContext from './FormContext'; import type {InputWrapperProps, ValidInputs} from './types'; -function InputWrapper({InputComponent, inputID, valueType = 'string', ...rest}: InputWrapperProps, ref: ForwardedRef) { +const textInputBasedComponents: ValidInputs[] = [TextInput, RoomNameInput]; + +function computeComponentSpecificRegistrationParams({ + InputComponent, + shouldSubmitForm, + multiline, + autoGrowHeight, + blurOnSubmit, +}: InputWrapperProps): { + readonly shouldSubmitForm: boolean; + readonly blurOnSubmit: boolean | undefined; + readonly shouldSetTouchedOnBlurOnly: boolean; +} { + if (textInputBasedComponents.includes(InputComponent)) { + const isEffectivelyMultiline = Boolean(multiline) || Boolean(autoGrowHeight); + + // If the user can use the hardware keyboard, they have access to an alternative way of inserting a new line + // (like a Shift+Enter keyboard shortcut). For simplicity, we assume that when there's no touch screen, it's a + // desktop setup with a keyboard. + const canUseHardwareKeyboard = !canUseTouchScreen(); + + // We want to avoid a situation when the user can't insert a new line. For single-line inputs, it's not a problem and we + // force-enable form submission. For multi-line inputs, ensure that it was requested to enable form submission for this specific + // input and that alternative ways exist to add a new line. + const shouldReallySubmitForm = isEffectivelyMultiline ? Boolean(shouldSubmitForm) && canUseHardwareKeyboard : true; + + return { + // There are inputs that don't have onBlur methods, to simulate the behavior of onBlur in e.g. checkbox, we had to + // use different methods like onPress. This introduced a problem that inputs that have the onBlur method were + // calling some methods too early or twice, so we had to add this check to prevent that side effect. + // For now this side effect happened only in `TextInput` components. + shouldSetTouchedOnBlurOnly: true, + blurOnSubmit: (isEffectivelyMultiline && shouldReallySubmitForm) || blurOnSubmit, + shouldSubmitForm: shouldReallySubmitForm, + }; + } + + return { + shouldSetTouchedOnBlurOnly: false, + // Forward the originally provided value + blurOnSubmit, + shouldSubmitForm: false, + }; +} + +function InputWrapper(props: InputWrapperProps, ref: ForwardedRef) { + const {InputComponent, inputID, valueType = 'string', shouldSubmitForm: propShouldSubmitForm, ...rest} = props; const {registerInput} = useContext(FormContext); - // There are inputs that don't have onBlur methods, to simulate the behavior of onBlur in e.g. checkbox, we had to - // use different methods like onPress. This introduced a problem that inputs that have the onBlur method were - // calling some methods too early or twice, so we had to add this check to prevent that side effect. - // For now this side effect happened only in `TextInput` components. - const shouldSetTouchedOnBlurOnly = InputComponent === TextInput; + + const {shouldSetTouchedOnBlurOnly, blurOnSubmit, shouldSubmitForm} = computeComponentSpecificRegistrationParams(props); // TODO: Sometimes we return too many props with register input, so we need to consider if it's better to make the returned type more general and disregard the issue, or we would like to omit the unused props somehow. // eslint-disable-next-line react/jsx-props-no-spreading, @typescript-eslint/no-explicit-any - return ; + return ; } InputWrapper.displayName = 'InputWrapper'; diff --git a/src/components/Form/types.ts b/src/components/Form/types.ts index b2c7aea3f3cf..353a6927caf7 100644 --- a/src/components/Form/types.ts +++ b/src/components/Form/types.ts @@ -1,5 +1,5 @@ import type {ComponentProps, FocusEvent, Key, MutableRefObject, ReactNode, Ref} from 'react'; -import type {GestureResponderEvent, NativeSyntheticEvent, StyleProp, TextInputFocusEventData, ViewStyle} from 'react-native'; +import type {GestureResponderEvent, NativeSyntheticEvent, StyleProp, TextInputFocusEventData, TextInputSubmitEditingEventData, ViewStyle} from 'react-native'; import type AddressSearch from '@components/AddressSearch'; import type AmountTextInput from '@components/AmountTextInput'; import type CheckboxWithLabel from '@components/CheckboxWithLabel'; @@ -40,12 +40,22 @@ type BaseInputProps = { isFocused?: boolean; measureLayout?: (ref: unknown, callback: MeasureLayoutOnSuccessCallback) => void; focus?: () => void; + multiline?: boolean; + autoGrowHeight?: boolean; + blurOnSubmit?: boolean; + onSubmitEditing?: (event: NativeSyntheticEvent) => void; }; type InputWrapperProps = Omit & ComponentProps & { InputComponent: TInput; inputID: string; + + /** + * Should the containing form be submitted when this input is submitted itself? + * Currently, meaningful only for text inputs. + */ + shouldSubmitForm?: boolean; }; type ExcludeDraft = T extends `${string}Draft` ? never : T; @@ -89,7 +99,7 @@ type FormProps = { disablePressOnEnter?: boolean; }; -type RegisterInput = (inputID: keyof Form, inputProps: TInputProps) => TInputProps; +type RegisterInput = (inputID: keyof Form, shouldSubmitForm: boolean, inputProps: TInputProps) => TInputProps; type InputRefs = Record>; diff --git a/src/components/FormSubmit/index.native.tsx b/src/components/FormSubmit/index.native.tsx deleted file mode 100644 index 5eae7b51d988..000000000000 --- a/src/components/FormSubmit/index.native.tsx +++ /dev/null @@ -1,18 +0,0 @@ -import React from 'react'; -import {View} from 'react-native'; -import type {FormSubmitProps, FormSubmitRef} from './types'; - -function FormSubmit({style, children}: FormSubmitProps, ref: FormSubmitRef) { - return ( - - {children} - - ); -} - -FormSubmit.displayName = 'FormSubmit'; - -export default React.forwardRef(FormSubmit); diff --git a/src/components/FormSubmit/index.tsx b/src/components/FormSubmit/index.tsx deleted file mode 100644 index 2ccd006bf322..000000000000 --- a/src/components/FormSubmit/index.tsx +++ /dev/null @@ -1,86 +0,0 @@ -import type {KeyboardEvent} from 'react'; -import React, {useEffect} from 'react'; -import {View} from 'react-native'; -import * as ComponentUtils from '@libs/ComponentUtils'; -import isEnterWhileComposition from '@libs/KeyboardShortcut/isEnterWhileComposition'; -import CONST from '@src/CONST'; -import type {FormSubmitProps, FormSubmitRef} from './types'; - -function FormSubmit({children, onSubmit, style}: FormSubmitProps, ref: FormSubmitRef) { - /** - * Calls the submit callback when ENTER is pressed on a form element. - */ - const submitForm = (event: KeyboardEvent) => { - // ENTER is pressed with modifier key or during text composition, do not submit the form - if (event.shiftKey || event.key !== CONST.KEYBOARD_SHORTCUTS.ENTER.shortcutKey || isEnterWhileComposition(event)) { - return; - } - - const eventTarget = event.target as HTMLElement; - - const tagName = eventTarget?.tagName ?? ''; - - // ENTER is pressed on INPUT or SELECT element, call the submit callback. - if (tagName === 'INPUT' || tagName === 'SELECT') { - onSubmit(); - return; - } - - // Pressing Enter on TEXTAREA element adds a new line. When `dataset.submitOnEnter` prop is passed, call the submit callback. - if (tagName === 'TEXTAREA' && (eventTarget?.dataset?.submitOnEnter ?? 'false') === 'true') { - event.preventDefault(); - onSubmit(); - return; - } - - // ENTER is pressed on checkbox element, call the submit callback. - if (eventTarget?.role === 'checkbox') { - onSubmit(); - } - }; - - const preventDefaultFormBehavior = (e: SubmitEvent) => e.preventDefault(); - - useEffect(() => { - if (!(ref && 'current' in ref)) { - return; - } - - const form = ref.current as HTMLFormElement | null; - - if (!form) { - return; - } - - // Prevent the browser from applying its own validation, which affects the email input - form.setAttribute('novalidate', ''); - - form.addEventListener('submit', preventDefaultFormBehavior); - - return () => { - if (!form) { - return; - } - - form.removeEventListener('submit', preventDefaultFormBehavior); - }; - }, [ref]); - - return ( - // React-native-web prevents event bubbling on TextInput for key presses - // https://github.com/necolas/react-native-web/blob/fa47f80d34ee6cde2536b2a2241e326f84b633c4/packages/react-native-web/src/exports/TextInput/index.js#L272 - // Thus use capture phase. - - {children} - - ); -} - -FormSubmit.displayName = 'FormSubmitWithRef'; - -export default React.forwardRef(FormSubmit); diff --git a/src/components/FormSubmit/types.ts b/src/components/FormSubmit/types.ts deleted file mode 100644 index 722a3fbf746e..000000000000 --- a/src/components/FormSubmit/types.ts +++ /dev/null @@ -1,13 +0,0 @@ -import type {ForwardedRef} from 'react'; -import type React from 'react'; -import type {StyleProp, View, ViewStyle} from 'react-native'; - -type FormSubmitProps = { - children: React.ReactNode; - onSubmit: () => void; - style?: StyleProp; -}; - -type FormSubmitRef = ForwardedRef; - -export type {FormSubmitProps, FormSubmitRef}; diff --git a/src/components/MoneyReportHeader.tsx b/src/components/MoneyReportHeader.tsx index a5b0d6707421..c4c85136e846 100644 --- a/src/components/MoneyReportHeader.tsx +++ b/src/components/MoneyReportHeader.tsx @@ -88,8 +88,8 @@ function MoneyReportHeader({session, policy, chatReport, nextStep, report: money // The submit button should be success green colour only if the user is submitter and the policy does not have Scheduled Submit turned on const isWaitingForSubmissionFromCurrentUser = useMemo( - () => chatReport?.isOwnPolicyExpenseChat && !(policy.harvesting?.enabled ?? policy.isHarvestingEnabled), - [chatReport?.isOwnPolicyExpenseChat, policy.harvesting?.enabled, policy.isHarvestingEnabled], + () => chatReport?.isOwnPolicyExpenseChat && !policy.harvesting?.enabled, + [chatReport?.isOwnPolicyExpenseChat, policy.harvesting?.enabled], ); const threeDotsMenuItems = [HeaderUtils.getPinMenuItem(moneyRequestReport)]; diff --git a/src/components/ReportActionItem/ReportPreview.tsx b/src/components/ReportActionItem/ReportPreview.tsx index 311e63332f5c..cfe06b2c0a62 100644 --- a/src/components/ReportActionItem/ReportPreview.tsx +++ b/src/components/ReportActionItem/ReportPreview.tsx @@ -159,8 +159,8 @@ function ReportPreview({ // The submit button should be success green colour only if the user is submitter and the policy does not have Scheduled Submit turned on const isWaitingForSubmissionFromCurrentUser = useMemo( - () => chatReport?.isOwnPolicyExpenseChat && !(policy?.harvesting?.enabled ?? policy?.isHarvestingEnabled), - [chatReport?.isOwnPolicyExpenseChat, policy?.harvesting?.enabled, policy?.isHarvestingEnabled], + () => chatReport?.isOwnPolicyExpenseChat && !policy?.harvesting?.enabled, + [chatReport?.isOwnPolicyExpenseChat, policy?.harvesting?.enabled], ); const getDisplayAmount = (): string => { diff --git a/src/components/RoomNameInput/index.js b/src/components/RoomNameInput/index.js index 61f004a47b96..e3c5a86ff945 100644 --- a/src/components/RoomNameInput/index.js +++ b/src/components/RoomNameInput/index.js @@ -6,7 +6,7 @@ import * as RoomNameInputUtils from '@libs/RoomNameInputUtils'; import CONST from '@src/CONST'; import * as roomNameInputPropTypes from './roomNameInputPropTypes'; -function RoomNameInput({isFocused, autoFocus, disabled, errorText, forwardedRef, value, onBlur, onChangeText, onInputChange, shouldDelayFocus}) { +function RoomNameInput({isFocused, autoFocus, disabled, errorText, forwardedRef, value, onBlur, onChangeText, onInputChange, onSubmitEditing, returnKeyType, shouldDelayFocus}) { const {translate} = useLocalize(); const [selection, setSelection] = useState(); @@ -52,6 +52,8 @@ function RoomNameInput({isFocused, autoFocus, disabled, errorText, forwardedRef, value={value.substring(1)} // Since the room name always starts with a prefix, we omit the first character to avoid displaying it twice. selection={selection} onSelectionChange={(event) => setSelection(event.nativeEvent.selection)} + onSubmitEditing={onSubmitEditing} + returnKeyType={returnKeyType} errorText={errorText} autoCapitalize="none" onBlur={(event) => isFocused && onBlur(event)} diff --git a/src/components/RoomNameInput/index.native.js b/src/components/RoomNameInput/index.native.js index a2c09996ad34..bae347fca3d2 100644 --- a/src/components/RoomNameInput/index.native.js +++ b/src/components/RoomNameInput/index.native.js @@ -7,7 +7,7 @@ import * as RoomNameInputUtils from '@libs/RoomNameInputUtils'; import CONST from '@src/CONST'; import * as roomNameInputPropTypes from './roomNameInputPropTypes'; -function RoomNameInput({isFocused, autoFocus, disabled, errorText, forwardedRef, value, onBlur, onChangeText, onInputChange, shouldDelayFocus}) { +function RoomNameInput({isFocused, autoFocus, disabled, errorText, forwardedRef, value, onBlur, onChangeText, onInputChange, onSubmitEditing, returnKeyType, shouldDelayFocus}) { const {translate} = useLocalize(); /** @@ -42,6 +42,8 @@ function RoomNameInput({isFocused, autoFocus, disabled, errorText, forwardedRef, maxLength={CONST.REPORT.MAX_ROOM_NAME_LENGTH} keyboardType={keyboardType} // this is a bit hacky solution to a RN issue https://github.com/facebook/react-native/issues/27449 onBlur={(event) => isFocused && onBlur(event)} + onSubmitEditing={onSubmitEditing} + returnKeyType={returnKeyType} autoFocus={isFocused && autoFocus} autoCapitalize="none" shouldDelayFocus={shouldDelayFocus} diff --git a/src/components/RoomNameInput/roomNameInputPropTypes.js b/src/components/RoomNameInput/roomNameInputPropTypes.js index f634c6e0b3d6..aa547354a4c5 100644 --- a/src/components/RoomNameInput/roomNameInputPropTypes.js +++ b/src/components/RoomNameInput/roomNameInputPropTypes.js @@ -18,6 +18,12 @@ const propTypes = { /** A ref forwarded to the TextInput */ forwardedRef: refPropTypes, + /** On submit editing handler provided by the FormProvider */ + onSubmitEditing: PropTypes.func, + + /** Return key type provided to the TextInput */ + returnKeyType: PropTypes.string, + /** The ID used to uniquely identify the input in a Form */ inputID: PropTypes.string, @@ -40,6 +46,8 @@ const defaultProps = { disabled: false, errorText: '', forwardedRef: () => {}, + onSubmitEditing: () => {}, + returnKeyType: undefined, inputID: undefined, onBlur: () => {}, diff --git a/src/components/TextInput/BaseTextInput/baseTextInputPropTypes.js b/src/components/TextInput/BaseTextInput/baseTextInputPropTypes.js index e6077bde71b3..b0a73a650d82 100644 --- a/src/components/TextInput/BaseTextInput/baseTextInputPropTypes.js +++ b/src/components/TextInput/BaseTextInput/baseTextInputPropTypes.js @@ -90,9 +90,6 @@ const propTypes = { /** Whether we should wait before focusing the TextInput, useful when using transitions */ shouldDelayFocus: PropTypes.bool, - /** Indicate whether pressing Enter on multiline input is allowed to submit the form. */ - submitOnEnter: PropTypes.bool, - /** Indicate whether input is multiline */ multiline: PropTypes.bool, @@ -133,7 +130,6 @@ const defaultProps = { prefixCharacter: '', onInputChange: () => {}, shouldDelayFocus: false, - submitOnEnter: false, icon: null, shouldUseDefaultValue: false, multiline: false, diff --git a/src/components/TextInput/BaseTextInput/index.native.tsx b/src/components/TextInput/BaseTextInput/index.native.tsx index 6f00f06c4c5e..c6429747bf97 100644 --- a/src/components/TextInput/BaseTextInput/index.native.tsx +++ b/src/components/TextInput/BaseTextInput/index.native.tsx @@ -51,7 +51,6 @@ function BaseTextInput( hint = '', onInputChange = () => {}, shouldDelayFocus = false, - submitOnEnter = false, multiline = false, shouldInterceptSwipe = false, autoCorrect = true, @@ -376,9 +375,6 @@ function BaseTextInput( selection={inputProps.selection} readOnly={isReadOnly} defaultValue={defaultValue} - // FormSubmit Enter key handler does not have access to direct props. - // `dataset.submitOnEnter` is used to indicate that pressing Enter on this input should call the submit callback. - dataSet={{submitOnEnter: isMultiline && submitOnEnter}} /> {inputProps.isLoading && ( {}, shouldDelayFocus = false, - submitOnEnter = false, multiline = false, shouldInterceptSwipe = false, autoCorrect = true, @@ -396,9 +395,6 @@ function BaseTextInput( selection={inputProps.selection} readOnly={isReadOnly} defaultValue={defaultValue} - // FormSubmit Enter key handler does not have access to direct props. - // `dataset.submitOnEnter` is used to indicate that pressing Enter on this input should call the submit callback. - dataSet={{submitOnEnter: isMultiline && submitOnEnter}} /> {inputProps.isLoading && ( { + if (!value) { + return; + } + + currentUserAccountID = value?.accountID ?? -1; + }, +}); function parseMessage(messages: Message[] | undefined) { let nextStepHTML = ''; @@ -27,5 +50,274 @@ function parseMessage(messages: Message[] | undefined) { return `${formattedHtml}`; } -// eslint-disable-next-line import/prefer-default-export -export {parseMessage}; +type BuildNextStepParameters = { + isPaidWithWallet?: boolean; +}; + +/** + * Generates an optimistic nextStep based on a current report status and other properties. + * + * @param report + * @param predictedNextStatus - a next expected status of the report + * @param parameters.isPaidWithWallet - Whether a report has been paid with the wallet or outside of Expensify + * @returns nextStep + */ +function buildNextStep(report: Report | EmptyObject, predictedNextStatus: ValueOf, {isPaidWithWallet}: BuildNextStepParameters = {}): ReportNextStep | null { + if (!ReportUtils.isExpenseReport(report)) { + return null; + } + + const {policyID = '', ownerAccountID = -1, managerID = -1} = report; + const policy = ReportUtils.getPolicy(policyID); + const {submitsTo, harvesting, isPreventSelfApprovalEnabled, autoReportingFrequency, autoReportingOffset} = policy; + const isOwner = currentUserAccountID === ownerAccountID; + const isManager = currentUserAccountID === managerID; + const isSelfApproval = currentUserAccountID === submitsTo; + const ownerLogin = PersonalDetailsUtils.getLoginsByAccountIDs([ownerAccountID])[0] ?? ''; + const managerDisplayName = isSelfApproval ? 'you' : ReportUtils.getDisplayNameForParticipant(submitsTo) ?? ''; + const type: ReportNextStep['type'] = 'neutral'; + let optimisticNextStep: ReportNextStep | null; + + switch (predictedNextStatus) { + // Generates an optimistic nextStep once a report has been opened + case CONST.REPORT.STATUS_NUM.OPEN: + // Self review + optimisticNextStep = { + type, + title: 'Next Steps:', + message: [ + { + text: 'Waiting for ', + }, + { + text: 'you', + type: 'strong', + }, + { + text: ' to ', + }, + { + text: 'submit', + type: 'strong', + }, + { + text: ' these expenses.', + }, + ], + }; + + // Scheduled submit enabled + if (harvesting?.enabled && autoReportingFrequency !== CONST.POLICY.AUTO_REPORTING_FREQUENCIES.MANUAL) { + optimisticNextStep.message = [ + { + text: 'These expenses are scheduled to ', + }, + ]; + let harvestingSuffix = ''; + + if (autoReportingFrequency) { + const currentDate = new Date(); + let autoSubmissionDate: Date | null = null; + let formattedDate = ''; + + if (autoReportingOffset === CONST.POLICY.AUTO_REPORTING_OFFSET.LAST_DAY_OF_MONTH) { + autoSubmissionDate = lastDayOfMonth(currentDate); + } else if (autoReportingOffset === CONST.POLICY.AUTO_REPORTING_OFFSET.LAST_BUSINESS_DAY_OF_MONTH) { + const lastBusinessDayOfMonth = DateUtils.getLastBusinessDayOfMonth(currentDate); + autoSubmissionDate = setDate(currentDate, lastBusinessDayOfMonth); + } else if (autoReportingOffset !== undefined) { + autoSubmissionDate = setDate(currentDate, autoReportingOffset); + } + + if (autoSubmissionDate) { + formattedDate = format(autoSubmissionDate, CONST.DATE.ORDINAL_DAY_OF_MONTH); + } + + const harvestingSuffixes: Record, string> = { + [CONST.POLICY.AUTO_REPORTING_FREQUENCIES.IMMEDIATE]: 'later today', + [CONST.POLICY.AUTO_REPORTING_FREQUENCIES.WEEKLY]: 'on Sunday', + [CONST.POLICY.AUTO_REPORTING_FREQUENCIES.SEMI_MONTHLY]: 'on the 1st and 16th of each month', + [CONST.POLICY.AUTO_REPORTING_FREQUENCIES.MONTHLY]: formattedDate ? `on the ${formattedDate} of each month` : '', + [CONST.POLICY.AUTO_REPORTING_FREQUENCIES.TRIP]: 'at the end of your trip', + [CONST.POLICY.AUTO_REPORTING_FREQUENCIES.INSTANT]: '', + [CONST.POLICY.AUTO_REPORTING_FREQUENCIES.MANUAL]: '', + }; + + if (harvestingSuffixes[autoReportingFrequency]) { + harvestingSuffix = ` ${harvestingSuffixes[autoReportingFrequency]}`; + } + } + + optimisticNextStep.message.push( + { + text: `automatically submit${harvestingSuffix}!`, + type: 'strong', + }, + { + text: ' No further action required!', + }, + ); + } + + // Prevented self submitting + if (isPreventSelfApprovalEnabled && isSelfApproval) { + optimisticNextStep.message = [ + { + text: "Oops! Looks like you're submitting to ", + }, + { + text: 'yourself', + type: 'strong', + }, + { + text: '. Approving your own reports is ', + }, + { + text: 'forbidden', + type: 'strong', + }, + { + text: ' by your policy. Please submit this report to someone else or contact your admin to change the person you submit to.', + }, + ]; + } + + break; + + // Generates an optimistic nextStep once a report has been submitted + case CONST.REPORT.STATUS_NUM.SUBMITTED: { + const verb = isManager ? 'review' : 'approve'; + + // Another owner + optimisticNextStep = { + type, + title: 'Next Steps:', + message: [ + { + text: ownerLogin, + type: 'strong', + }, + { + text: ' is waiting for ', + }, + { + text: 'you', + type: 'strong', + }, + { + text: ' to ', + }, + { + text: verb, + type: 'strong', + }, + { + text: ' these %expenses.', + }, + ], + }; + + // Self review & another reviewer + if (isOwner) { + optimisticNextStep.message = [ + { + text: 'Waiting for ', + }, + { + text: managerDisplayName, + type: 'strong', + }, + { + text: ' to ', + }, + { + text: verb, + type: 'strong', + }, + { + text: ' %expenses.', + }, + ]; + } + + break; + } + + // Generates an optimistic nextStep once a report has been approved + case CONST.REPORT.STATUS_NUM.APPROVED: + // Self review + optimisticNextStep = { + type, + title: 'Next Steps:', + message: [ + { + text: 'Waiting for ', + }, + { + text: 'you', + type: 'strong', + }, + { + text: ' to ', + }, + { + text: 'review', + type: 'strong', + }, + { + text: ' %expenses.', + }, + ], + }; + + // Another owner + if (!isOwner) { + optimisticNextStep.title = 'Finished!'; + optimisticNextStep.message = [ + { + text: 'No further action required!', + }, + ]; + } + + break; + + // Generates an optimistic nextStep once a report has been paid + case CONST.REPORT.STATUS_NUM.REIMBURSED: + // Paid with wallet + optimisticNextStep = { + type, + title: 'Finished!', + message: [ + { + text: 'You', + type: 'strong', + }, + { + text: ' have marked these expenses as ', + }, + { + text: 'paid', + type: 'strong', + }, + ], + }; + + // Paid outside of Expensify + if (isPaidWithWallet === false) { + optimisticNextStep.message?.push({text: ' outside of Expensify'}); + } + + optimisticNextStep.message?.push({text: '.'}); + + break; + + // Resets a nextStep + default: + optimisticNextStep = null; + } + + return optimisticNextStep; +} + +export {parseMessage, buildNextStep}; diff --git a/src/libs/OptionsListUtils.ts b/src/libs/OptionsListUtils.ts index b6518b361381..44449fecd517 100644 --- a/src/libs/OptionsListUtils.ts +++ b/src/libs/OptionsListUtils.ts @@ -1001,7 +1001,7 @@ function getCategoryListSections( } const filteredRecentlyUsedCategories = recentlyUsedCategories - .filter((categoryName) => !selectedOptionNames.includes(categoryName) && categories[categoryName].enabled) + .filter((categoryName) => !selectedOptionNames.includes(categoryName) && categories[categoryName]?.enabled) .map((categoryName) => ({ name: categoryName, enabled: categories[categoryName].enabled ?? false, diff --git a/src/libs/PolicyUtils.ts b/src/libs/PolicyUtils.ts index 0a7a2d40e0f6..0575e297da0c 100644 --- a/src/libs/PolicyUtils.ts +++ b/src/libs/PolicyUtils.ts @@ -106,7 +106,7 @@ function isExpensifyGuideTeam(email: string): boolean { */ const isPolicyAdmin = (policy: OnyxEntry | EmptyObject): boolean => policy?.role === CONST.POLICY.ROLE.ADMIN; -const isPolicyMember = (policyID: string, policies: Record): boolean => Object.values(policies).some((policy) => policy?.id === policyID); +const isPolicyMember = (policyID: string, policies: OnyxCollection): boolean => Object.values(policies ?? {}).some((policy) => policy?.id === policyID); /** * Create an object mapping member emails to their accountIDs. Filter for members without errors, and get the login email from the personalDetail object using the accountID. diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index ba78d8a2cc38..3bf909bf14d5 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -4646,7 +4646,7 @@ function shouldAutoFocusOnKeyPress(event: KeyboardEvent): boolean { /** * Navigates to the appropriate screen based on the presence of a private note for the current user. */ -function navigateToPrivateNotes(report: Report, session: Session) { +function navigateToPrivateNotes(report: OnyxEntry, session: OnyxEntry) { if (isEmpty(report) || isEmpty(session) || !session.accountID) { return; } diff --git a/src/libs/actions/IOU.ts b/src/libs/actions/IOU.ts index ce8957822a7a..65ac21017acf 100644 --- a/src/libs/actions/IOU.ts +++ b/src/libs/actions/IOU.ts @@ -32,6 +32,7 @@ import * as IOUUtils from '@libs/IOUUtils'; import * as LocalePhoneNumber from '@libs/LocalePhoneNumber'; import * as Localize from '@libs/Localize'; import Navigation from '@libs/Navigation/Navigation'; +import * as NextStepUtils from '@libs/NextStepUtils'; import * as NumberUtils from '@libs/NumberUtils'; import * as OptionsListUtils from '@libs/OptionsListUtils'; import Permissions from '@libs/Permissions'; @@ -396,6 +397,7 @@ function buildOnyxDataForMoneyRequest( policy?: OnyxTypes.Policy | EmptyObject, policyTags?: OnyxTypes.PolicyTags, policyCategories?: OnyxTypes.PolicyCategories, + optimisticNextStep?: OnyxTypes.ReportNextStep | null, needsToBeManuallySubmitted = true, ): [OnyxUpdate[], OnyxUpdate[], OnyxUpdate[]] { const isScanRequest = TransactionUtils.isScanRequest(transaction); @@ -501,6 +503,14 @@ function buildOnyxDataForMoneyRequest( }); } + if (!isEmptyObject(optimisticNextStep)) { + optimisticData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.NEXT_STEP}${iouReport.reportID}`, + value: optimisticNextStep, + }); + } + const successData: OnyxUpdate[] = []; if (isNewChatReport) { @@ -749,7 +759,7 @@ function getMoneyRequestInformation( isFromPaidPolicy = PolicyUtils.isPaidGroupPolicy(policy ?? null); // If the scheduled submit is turned off on the policy, user needs to manually submit the report which is indicated by GBR in LHN - needsToBeManuallySubmitted = isFromPaidPolicy && !(policy?.harvesting?.enabled ?? policy?.isHarvestingEnabled); + needsToBeManuallySubmitted = isFromPaidPolicy && !policy?.harvesting?.enabled; // If the linked expense report on paid policy is not draft, we need to create a new draft expense report if (iouReport && isFromPaidPolicy && !ReportUtils.isDraftExpenseReport(iouReport)) { @@ -864,6 +874,8 @@ function getMoneyRequestInformation( } : {}; + const optimisticNextStep = NextStepUtils.buildNextStep(iouReport, needsToBeManuallySubmitted ? CONST.REPORT.STATUS_NUM.OPEN : CONST.REPORT.STATUS_NUM.SUBMITTED); + // STEP 5: Build Onyx Data const [optimisticData, successData, failureData] = buildOnyxDataForMoneyRequest( chatReport, @@ -881,6 +893,7 @@ function getMoneyRequestInformation( policy, policyTags, policyCategories, + optimisticNextStep, needsToBeManuallySubmitted, ); @@ -3130,6 +3143,7 @@ function getPayMoneyRequestParams(chatReport: OnyxTypes.Report, iouReport: OnyxT } const currentNextStep = allNextSteps[`${ONYXKEYS.COLLECTION.NEXT_STEP}${iouReport.reportID}`] ?? null; + const optimisticNextStep = NextStepUtils.buildNextStep(iouReport, CONST.REPORT.STATUS_NUM.REIMBURSED, {isPaidWithWallet: paymentMethodType === CONST.IOU.PAYMENT_TYPE.EXPENSIFY}); const optimisticData: OnyxUpdate[] = [ { @@ -3175,6 +3189,11 @@ function getPayMoneyRequestParams(chatReport: OnyxTypes.Report, iouReport: OnyxT key: ONYXKEYS.NVP_LAST_PAYMENT_METHOD, value: {[iouReport.policyID ?? '']: paymentMethodType}, }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.NEXT_STEP}${iouReport.reportID}`, + value: optimisticNextStep, + }, ]; const successData: OnyxUpdate[] = [ @@ -3219,20 +3238,12 @@ function getPayMoneyRequestParams(chatReport: OnyxTypes.Report, iouReport: OnyxT key: `${ONYXKEYS.COLLECTION.REPORT}${chatReport.reportID}`, value: chatReport, }, - ]; - - if (currentNextStep) { - optimisticData.push({ - onyxMethod: Onyx.METHOD.SET, - key: `${ONYXKEYS.COLLECTION.NEXT_STEP}${iouReport.reportID}`, - value: null, - }); - failureData.push({ + { onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.NEXT_STEP}${iouReport.reportID}`, value: currentNextStep, - }); - } + }, + ]; // In case the report preview action is loaded locally, let's update it. if (optimisticReportPreviewAction) { @@ -3297,8 +3308,8 @@ function sendMoneyWithWallet(report: OnyxTypes.Report, amount: number, currency: function approveMoneyRequest(expenseReport: OnyxTypes.Report | EmptyObject) { const currentNextStep = allNextSteps[`${ONYXKEYS.COLLECTION.NEXT_STEP}${expenseReport.reportID}`] ?? null; - const optimisticApprovedReportAction = ReportUtils.buildOptimisticApprovedReportAction(expenseReport.total ?? 0, expenseReport.currency ?? '', expenseReport.reportID); + const optimisticNextStep = NextStepUtils.buildNextStep(expenseReport, CONST.REPORT.STATUS_NUM.APPROVED); const optimisticReportActionsData: OnyxUpdate = { onyxMethod: Onyx.METHOD.MERGE, @@ -3321,7 +3332,12 @@ function approveMoneyRequest(expenseReport: OnyxTypes.Report | EmptyObject) { statusNum: CONST.REPORT.STATUS_NUM.APPROVED, }, }; - const optimisticData: OnyxUpdate[] = [optimisticIOUReportData, optimisticReportActionsData]; + const optimisticNextStepData: OnyxUpdate = { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.NEXT_STEP}${expenseReport.reportID}`, + value: optimisticNextStep, + }; + const optimisticData: OnyxUpdate[] = [optimisticIOUReportData, optimisticReportActionsData, optimisticNextStepData]; const successData: OnyxUpdate[] = [ { @@ -3345,20 +3361,12 @@ function approveMoneyRequest(expenseReport: OnyxTypes.Report | EmptyObject) { }, }, }, - ]; - - if (currentNextStep) { - optimisticData.push({ - onyxMethod: Onyx.METHOD.SET, - key: `${ONYXKEYS.COLLECTION.NEXT_STEP}${expenseReport.reportID}`, - value: null, - }); - failureData.push({ + { onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.NEXT_STEP}${expenseReport.reportID}`, value: currentNextStep, - }); - } + }, + ]; const parameters: ApproveMoneyRequestParams = { reportID: expenseReport.reportID, @@ -3370,11 +3378,11 @@ function approveMoneyRequest(expenseReport: OnyxTypes.Report | EmptyObject) { function submitReport(expenseReport: OnyxTypes.Report) { const currentNextStep = allNextSteps[`${ONYXKEYS.COLLECTION.NEXT_STEP}${expenseReport.reportID}`] ?? null; - const optimisticSubmittedReportAction = ReportUtils.buildOptimisticSubmittedReportAction(expenseReport?.total ?? 0, expenseReport.currency ?? '', expenseReport.reportID); const parentReport = ReportUtils.getReport(expenseReport.parentReportID); const policy = ReportUtils.getPolicy(expenseReport.policyID); const isCurrentUserManager = currentUserPersonalDetails.accountID === expenseReport.managerID; + const optimisticNextStep = NextStepUtils.buildNextStep(expenseReport, CONST.REPORT.STATUS_NUM.SUBMITTED); const optimisticData: OnyxUpdate[] = [ { @@ -3398,6 +3406,11 @@ function submitReport(expenseReport: OnyxTypes.Report) { statusNum: CONST.REPORT.STATUS_NUM.SUBMITTED, }, }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.NEXT_STEP}${expenseReport.reportID}`, + value: optimisticNextStep, + }, ]; if (parentReport?.reportID) { @@ -3443,6 +3456,11 @@ function submitReport(expenseReport: OnyxTypes.Report) { stateNum: CONST.REPORT.STATE_NUM.OPEN, }, }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.NEXT_STEP}${expenseReport.reportID}`, + value: currentNextStep, + }, ]; if (parentReport?.reportID) { @@ -3456,19 +3474,6 @@ function submitReport(expenseReport: OnyxTypes.Report) { }); } - if (currentNextStep) { - optimisticData.push({ - onyxMethod: Onyx.METHOD.SET, - key: `${ONYXKEYS.COLLECTION.NEXT_STEP}${expenseReport.reportID}`, - value: null, - }); - failureData.push({ - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.NEXT_STEP}${expenseReport.reportID}`, - value: currentNextStep, - }); - } - const parameters: SubmitReportParams = { reportID: expenseReport.reportID, managerAccountID: policy.submitsTo ?? expenseReport.managerID, diff --git a/src/pages/ReportDetailsPage.js b/src/pages/ReportDetailsPage.tsx similarity index 66% rename from src/pages/ReportDetailsPage.js rename to src/pages/ReportDetailsPage.tsx index 99e1cea8280a..654e8330a582 100644 --- a/src/pages/ReportDetailsPage.js +++ b/src/pages/ReportDetailsPage.tsx @@ -1,8 +1,9 @@ -import PropTypes from 'prop-types'; +import type {StackScreenProps} from '@react-navigation/stack'; import React, {useEffect, useMemo} from 'react'; import {ScrollView, View} from 'react-native'; +import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; import {withOnyx} from 'react-native-onyx'; -import _ from 'underscore'; +import type {ValueOf} from 'type-fest'; import FullPageNotFoundView from '@components/BlockingViews/FullPageNotFoundView'; import DisplayNames from '@components/DisplayNames'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; @@ -11,89 +12,86 @@ import MenuItem from '@components/MenuItem'; import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; import MultipleAvatars from '@components/MultipleAvatars'; import OfflineWithFeedback from '@components/OfflineWithFeedback'; -import {withNetwork} from '@components/OnyxProvider'; import ParentNavigationSubtitle from '@components/ParentNavigationSubtitle'; -import participantPropTypes from '@components/participantPropTypes'; import PressableWithoutFeedback from '@components/Pressable/PressableWithoutFeedback'; import RoomHeaderAvatars from '@components/RoomHeaderAvatars'; import ScreenWrapper from '@components/ScreenWrapper'; -import withLocalize, {withLocalizePropTypes} from '@components/withLocalize'; +import useLocalize from '@hooks/useLocalize'; +import useNetwork from '@hooks/useNetwork'; import useThemeStyles from '@hooks/useThemeStyles'; -import compose from '@libs/compose'; import Navigation from '@libs/Navigation/Navigation'; +import type {ReportDetailsNavigatorParamList} from '@libs/Navigation/types'; import * as OptionsListUtils from '@libs/OptionsListUtils'; import * as PolicyUtils from '@libs/PolicyUtils'; import * as ReportUtils from '@libs/ReportUtils'; import * as Report from '@userActions/Report'; import CONST from '@src/CONST'; +import type {TranslationPaths} from '@src/languages/types'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; +import type SCREENS from '@src/SCREENS'; +import type * as OnyxTypes from '@src/types/onyx'; +import type DeepValueOf from '@src/types/utils/DeepValueOf'; +import {isEmptyObject} from '@src/types/utils/EmptyObject'; +import type IconAsset from '@src/types/utils/IconAsset'; +import type {WithReportOrNotFoundProps} from './home/report/withReportOrNotFound'; import withReportOrNotFound from './home/report/withReportOrNotFound'; -import reportPropTypes from './reportPropTypes'; -const propTypes = { - ...withLocalizePropTypes, - - /** The report currently being looked at */ - report: reportPropTypes.isRequired, - - /** The policies which the user has access to and which the report could be tied to */ - policies: PropTypes.shape({ - /** Name of the policy */ - name: PropTypes.string, - }), - - /** Route params */ - route: PropTypes.shape({ - params: PropTypes.shape({ - /** Report ID passed via route r/:reportID/details */ - reportID: PropTypes.string, - }), - }).isRequired, +type ReportDetailsPageMenuItem = { + key: DeepValueOf; + translationKey: TranslationPaths; + icon: IconAsset; + isAnonymousAction: boolean; + action: () => void; + brickRoadIndicator?: ValueOf; + subtitle?: number; +}; +type ReportDetailsPageOnyxProps = { /** Personal details of all the users */ - personalDetails: PropTypes.objectOf(participantPropTypes), -}; + personalDetails: OnyxCollection; -const defaultProps = { - policies: {}, - personalDetails: {}, + /** Session info for the currently logged in user. */ + session: OnyxEntry; }; +type ReportDetailsPageProps = ReportDetailsPageOnyxProps & WithReportOrNotFoundProps & StackScreenProps; -function ReportDetailsPage(props) { +function ReportDetailsPage({policies, report, session, personalDetails}: ReportDetailsPageProps) { + const {translate} = useLocalize(); + const {isOffline} = useNetwork(); const styles = useThemeStyles(); - const policy = useMemo(() => props.policies[`${ONYXKEYS.COLLECTION.POLICY}${props.report.policyID}`], [props.policies, props.report.policyID]); - const isPolicyAdmin = useMemo(() => PolicyUtils.isPolicyAdmin(policy), [policy]); - const isPolicyMember = useMemo(() => PolicyUtils.isPolicyMember(props.report.policyID, props.policies), [props.report.policyID, props.policies]); - const shouldUseFullTitle = useMemo(() => ReportUtils.shouldUseFullTitleToDisplay(props.report), [props.report]); - const isChatRoom = useMemo(() => ReportUtils.isChatRoom(props.report), [props.report]); - const isThread = useMemo(() => ReportUtils.isChatThread(props.report), [props.report]); - const isUserCreatedPolicyRoom = useMemo(() => ReportUtils.isUserCreatedPolicyRoom(props.report), [props.report]); - const isArchivedRoom = useMemo(() => ReportUtils.isArchivedRoom(props.report), [props.report]); - const isMoneyRequestReport = useMemo(() => ReportUtils.isMoneyRequestReport(props.report), [props.report]); - const canEditReportDescription = useMemo(() => ReportUtils.canEditReportDescription(props.report, policy), [props.report, policy]); - const shouldShowReportDescription = isChatRoom && (canEditReportDescription || !_.isEmpty(props.report.description)); + const policy = useMemo(() => policies?.[`${ONYXKEYS.COLLECTION.POLICY}${report?.policyID ?? ''}`], [policies, report?.policyID]); + const isPolicyAdmin = useMemo(() => PolicyUtils.isPolicyAdmin(policy ?? null), [policy]); + const isPolicyMember = useMemo(() => PolicyUtils.isPolicyMember(report?.policyID ?? '', policies), [report?.policyID, policies]); + const shouldUseFullTitle = useMemo(() => ReportUtils.shouldUseFullTitleToDisplay(report), [report]); + const isChatRoom = useMemo(() => ReportUtils.isChatRoom(report), [report]); + const isThread = useMemo(() => ReportUtils.isChatThread(report), [report]); + const isUserCreatedPolicyRoom = useMemo(() => ReportUtils.isUserCreatedPolicyRoom(report), [report]); + const isArchivedRoom = useMemo(() => ReportUtils.isArchivedRoom(report), [report]); + const isMoneyRequestReport = useMemo(() => ReportUtils.isMoneyRequestReport(report), [report]); + const canEditReportDescription = useMemo(() => ReportUtils.canEditReportDescription(report, policy), [report, policy]); + const shouldShowReportDescription = isChatRoom && (canEditReportDescription || report.description !== ''); // eslint-disable-next-line react-hooks/exhaustive-deps -- policy is a dependency because `getChatRoomSubtitle` calls `getPolicyName` which in turn retrieves the value from the `policy` value stored in Onyx - const chatRoomSubtitle = useMemo(() => ReportUtils.getChatRoomSubtitle(props.report), [props.report, policy]); - const parentNavigationSubtitleData = ReportUtils.getParentNavigationSubtitle(props.report); - const participants = useMemo(() => ReportUtils.getVisibleMemberIDs(props.report), [props.report]); + const chatRoomSubtitle = useMemo(() => ReportUtils.getChatRoomSubtitle(report), [report, policy]); + const parentNavigationSubtitleData = ReportUtils.getParentNavigationSubtitle(report); + const participants = useMemo(() => ReportUtils.getVisibleMemberIDs(report), [report]); - const isGroupDMChat = useMemo(() => ReportUtils.isDM(props.report) && participants.length > 1, [props.report, participants.length]); + const isGroupDMChat = useMemo(() => ReportUtils.isDM(report) && participants.length > 1, [report, participants.length]); - const isPrivateNotesFetchTriggered = !_.isUndefined(props.report.isLoadingPrivateNotes); + const isPrivateNotesFetchTriggered = report?.isLoadingPrivateNotes !== undefined; useEffect(() => { // Do not fetch private notes if isLoadingPrivateNotes is already defined, or if network is offline. - if (isPrivateNotesFetchTriggered || props.network.isOffline) { + if (isPrivateNotesFetchTriggered || isOffline) { return; } - Report.getReportPrivateNote(props.report.reportID); - }, [props.report.reportID, props.network.isOffline, isPrivateNotesFetchTriggered]); + Report.getReportPrivateNote(report?.reportID ?? ''); + }, [report?.reportID, isOffline, isPrivateNotesFetchTriggered]); - const menuItems = useMemo(() => { - const items = []; + const menuItems: ReportDetailsPageMenuItem[] = useMemo(() => { + const items: ReportDetailsPageMenuItem[] = []; if (!isGroupDMChat) { items.push({ @@ -101,7 +99,7 @@ function ReportDetailsPage(props) { translationKey: 'common.shareCode', icon: Expensicons.QrCode, isAnonymousAction: true, - action: () => Navigation.navigate(ROUTES.REPORT_WITH_ID_DETAILS_SHARE_CODE.getRoute(props.report.reportID)), + action: () => Navigation.navigate(ROUTES.REPORT_WITH_ID_DETAILS_SHARE_CODE.getRoute(report?.reportID ?? '')), }); } @@ -120,21 +118,21 @@ function ReportDetailsPage(props) { subtitle: participants.length, isAnonymousAction: false, action: () => { - if (isUserCreatedPolicyRoom && !props.report.parentReportID) { - Navigation.navigate(ROUTES.ROOM_MEMBERS.getRoute(props.report.reportID)); + if (isUserCreatedPolicyRoom && !report?.parentReportID) { + Navigation.navigate(ROUTES.ROOM_MEMBERS.getRoute(report?.reportID ?? '')); } else { - Navigation.navigate(ROUTES.REPORT_PARTICIPANTS.getRoute(props.report.reportID)); + Navigation.navigate(ROUTES.REPORT_PARTICIPANTS.getRoute(report?.reportID ?? '')); } }, }); - } else if (isUserCreatedPolicyRoom && (!participants.length || !isPolicyMember) && !props.report.parentReportID) { + } else if (isUserCreatedPolicyRoom && (!participants.length || !isPolicyMember) && !report?.parentReportID) { items.push({ key: CONST.REPORT_DETAILS_MENU_ITEM.INVITE, translationKey: 'common.invite', icon: Expensicons.Users, isAnonymousAction: false, action: () => { - Navigation.navigate(ROUTES.ROOM_INVITE.getRoute(props.report.reportID)); + Navigation.navigate(ROUTES.ROOM_INVITE.getRoute(report?.reportID ?? '')); }, }); } @@ -145,31 +143,31 @@ function ReportDetailsPage(props) { icon: Expensicons.Gear, isAnonymousAction: false, action: () => { - Navigation.navigate(ROUTES.REPORT_SETTINGS.getRoute(props.report.reportID)); + Navigation.navigate(ROUTES.REPORT_SETTINGS.getRoute(report?.reportID ?? '')); }, }); // Prevent displaying private notes option for threads and task reports - if (!isThread && !isMoneyRequestReport && !ReportUtils.isTaskReport(props.report)) { + if (!isThread && !isMoneyRequestReport && !ReportUtils.isTaskReport(report)) { items.push({ key: CONST.REPORT_DETAILS_MENU_ITEM.PRIVATE_NOTES, translationKey: 'privateNotes.title', icon: Expensicons.Pencil, isAnonymousAction: false, - action: () => ReportUtils.navigateToPrivateNotes(props.report, props.session), - brickRoadIndicator: Report.hasErrorInPrivateNotes(props.report) ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : '', + action: () => ReportUtils.navigateToPrivateNotes(report, session), + brickRoadIndicator: Report.hasErrorInPrivateNotes(report) ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined, }); } return items; - }, [isArchivedRoom, participants.length, isThread, isMoneyRequestReport, props.report, isGroupDMChat, isPolicyMember, isUserCreatedPolicyRoom, props.session]); + }, [isArchivedRoom, participants.length, isThread, isMoneyRequestReport, report, isGroupDMChat, isPolicyMember, isUserCreatedPolicyRoom, session]); const displayNamesWithTooltips = useMemo(() => { const hasMultipleParticipants = participants.length > 1; - return ReportUtils.getDisplayNamesWithTooltips(OptionsListUtils.getPersonalDetailsForAccountIDs(participants, props.personalDetails), hasMultipleParticipants); - }, [participants, props.personalDetails]); + return ReportUtils.getDisplayNamesWithTooltips(OptionsListUtils.getPersonalDetailsForAccountIDs(participants, personalDetails), hasMultipleParticipants); + }, [participants, personalDetails]); - const icons = useMemo(() => ReportUtils.getIcons(props.report, props.personalDetails, null, '', -1, policy), [props.report, props.personalDetails, policy]); + const icons = useMemo(() => ReportUtils.getIcons(report, personalDetails, null, '', -1, policy), [report, personalDetails, policy]); const chatRoomSubtitleText = chatRoomSubtitle ? ( - + { Navigation.goBack(); - Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(props.report.reportID)); + Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(report?.reportID ?? '')); }} /> @@ -203,14 +201,14 @@ function ReportDetailsPage(props) { ) : ( )} { - Navigation.navigate(ROUTES.WORKSPACE_INITIAL.getRoute(props.report.policyID)); + Navigation.navigate(ROUTES.WORKSPACE_INITIAL.getRoute(report?.policyID ?? '')); }} > {chatRoomSubtitleText} @@ -233,41 +232,41 @@ function ReportDetailsPage(props) { ) : ( chatRoomSubtitleText )} - {!_.isEmpty(parentNavigationSubtitleData) && isMoneyRequestReport && ( + {!isEmptyObject(parentNavigationSubtitleData) && isMoneyRequestReport && ( )} {shouldShowReportDescription && ( - + Navigation.navigate(ROUTES.REPORT_DESCRIPTION.getRoute(props.report.reportID))} + description={translate('reportDescriptionPage.roomDescription')} + onPress={() => Navigation.navigate(ROUTES.REPORT_DESCRIPTION.getRoute(report.reportID))} /> )} - {_.map(menuItems, (item) => { + {menuItems.map((item) => { const brickRoadIndicator = - ReportUtils.hasReportNameError(props.report) && item.key === CONST.REPORT_DETAILS_MENU_ITEM.SETTINGS ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : ''; + ReportUtils.hasReportNameError(report) && item.key === CONST.REPORT_DETAILS_MENU_ITEM.SETTINGS ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined; return ( ); })} @@ -278,22 +277,14 @@ function ReportDetailsPage(props) { } ReportDetailsPage.displayName = 'ReportDetailsPage'; -ReportDetailsPage.propTypes = propTypes; -ReportDetailsPage.defaultProps = defaultProps; -export default compose( - withLocalize, - withReportOrNotFound(), - withNetwork(), - withOnyx({ +export default withReportOrNotFound()( + withOnyx({ personalDetails: { key: ONYXKEYS.PERSONAL_DETAILS_LIST, }, - policies: { - key: ONYXKEYS.COLLECTION.POLICY, - }, session: { key: ONYXKEYS.SESSION, }, - }), -)(ReportDetailsPage); + })(ReportDetailsPage), +); diff --git a/src/pages/iou/request/step/IOURequestStepDescription.js b/src/pages/iou/request/step/IOURequestStepDescription.js index 849f3276667e..9fd6e5159e29 100644 --- a/src/pages/iou/request/step/IOURequestStepDescription.js +++ b/src/pages/iou/request/step/IOURequestStepDescription.js @@ -8,7 +8,6 @@ import TextInput from '@components/TextInput'; import transactionPropTypes from '@components/transactionPropTypes'; import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; -import * as Browser from '@libs/Browser'; import compose from '@libs/compose'; import Navigation from '@libs/Navigation/Navigation'; import updateMultilineInputRange from '@libs/updateMultilineInputRange'; @@ -106,8 +105,7 @@ function IOURequestStepDescription({ }} autoGrowHeight containerStyles={[styles.autoGrowHeightMultilineInput]} - inputStyle={[styles.verticalAlignTop]} - submitOnEnter={!Browser.isMobile()} + shouldSubmitForm /> diff --git a/src/pages/tasks/NewTaskDescriptionPage.js b/src/pages/tasks/NewTaskDescriptionPage.js index b11e7c163755..4d84cac90537 100644 --- a/src/pages/tasks/NewTaskDescriptionPage.js +++ b/src/pages/tasks/NewTaskDescriptionPage.js @@ -11,7 +11,6 @@ import TextInput from '@components/TextInput'; import withLocalize, {withLocalizePropTypes} from '@components/withLocalize'; import useAutoFocusInput from '@hooks/useAutoFocusInput'; import useThemeStyles from '@hooks/useThemeStyles'; -import * as Browser from '@libs/Browser'; import compose from '@libs/compose'; import Navigation from '@libs/Navigation/Navigation'; import updateMultilineInputRange from '@libs/updateMultilineInputRange'; @@ -79,7 +78,7 @@ function NewTaskDescriptionPage(props) { updateMultilineInputRange(el); }} autoGrowHeight - submitOnEnter={!Browser.isMobile()} + shouldSubmitForm containerStyles={[styles.autoGrowHeightMultilineInput]} /> diff --git a/src/pages/tasks/NewTaskDetailsPage.js b/src/pages/tasks/NewTaskDetailsPage.js index 3dab58dfad04..4f4f2560a0d9 100644 --- a/src/pages/tasks/NewTaskDetailsPage.js +++ b/src/pages/tasks/NewTaskDetailsPage.js @@ -11,7 +11,6 @@ import TextInput from '@components/TextInput'; import withLocalize, {withLocalizePropTypes} from '@components/withLocalize'; import useAutoFocusInput from '@hooks/useAutoFocusInput'; import useThemeStyles from '@hooks/useThemeStyles'; -import * as Browser from '@libs/Browser'; import compose from '@libs/compose'; import * as ErrorUtils from '@libs/ErrorUtils'; import Navigation from '@libs/Navigation/Navigation'; @@ -111,7 +110,7 @@ function NewTaskDetailsPage(props) { label={props.translate('newTaskPage.descriptionOptional')} accessibilityLabel={props.translate('newTaskPage.descriptionOptional')} autoGrowHeight - submitOnEnter={!Browser.isMobile()} + shouldSubmitForm containerStyles={[styles.autoGrowHeightMultilineInput]} defaultValue={parser.htmlToMarkdown(parser.replace(taskDescription))} value={taskDescription} diff --git a/src/pages/tasks/TaskDescriptionPage.js b/src/pages/tasks/TaskDescriptionPage.js index c5dab0dc2f94..48be7022b187 100644 --- a/src/pages/tasks/TaskDescriptionPage.js +++ b/src/pages/tasks/TaskDescriptionPage.js @@ -12,7 +12,6 @@ import TextInput from '@components/TextInput'; import withCurrentUserPersonalDetails from '@components/withCurrentUserPersonalDetails'; import withLocalize, {withLocalizePropTypes} from '@components/withLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; -import * as Browser from '@libs/Browser'; import compose from '@libs/compose'; import Navigation from '@libs/Navigation/Navigation'; import * as ReportUtils from '@libs/ReportUtils'; @@ -116,7 +115,7 @@ function TaskDescriptionPage(props) { updateMultilineInputRange(inputRef.current); }} autoGrowHeight - submitOnEnter={!Browser.isMobile()} + shouldSubmitForm containerStyles={[styles.autoGrowHeightMultilineInput]} /> diff --git a/src/types/onyx/Policy.ts b/src/types/onyx/Policy.ts index 784cd546a961..6b339d6e3ed4 100644 --- a/src/types/onyx/Policy.ts +++ b/src/types/onyx/Policy.ts @@ -85,9 +85,6 @@ type Policy = { /** The scheduled submit frequency set up on this policy */ autoReportingFrequency?: ValueOf; - /** @deprecated Whether the scheduled submit is enabled */ - isHarvestingEnabled?: boolean; - /** Whether the scheduled submit is enabled */ harvesting?: { enabled: boolean; diff --git a/tests/perf-test/ReportScreen.perf-test.js b/tests/perf-test/ReportScreen.perf-test.js index 5a144e715f5b..e86d0bf4fa09 100644 --- a/tests/perf-test/ReportScreen.perf-test.js +++ b/tests/perf-test/ReportScreen.perf-test.js @@ -1,8 +1,7 @@ -import {act, fireEvent, screen} from '@testing-library/react-native'; +import {fireEvent, screen, waitFor} from '@testing-library/react-native'; import React from 'react'; import Onyx from 'react-native-onyx'; import {measurePerformance} from 'reassure'; -import _ from 'underscore'; import ComposeProviders from '../../src/components/ComposeProviders'; import DragAndDropProvider from '../../src/components/DragAndDrop/Provider'; import {LocaleContextProvider} from '../../src/components/LocaleContextProvider'; @@ -15,7 +14,10 @@ import * as Localize from '../../src/libs/Localize'; import ONYXKEYS from '../../src/ONYXKEYS'; import {ReportAttachmentsProvider} from '../../src/pages/home/report/ReportAttachmentsContext'; import ReportScreen from '../../src/pages/home/ReportScreen'; -import * as LHNTestUtils from '../utils/LHNTestUtils'; +import createCollection from '../utils/collections/createCollection'; +import createPersonalDetails from '../utils/collections/personalDetails'; +import createRandomPolicy from '../utils/collections/policies'; +import createRandomReport from '../utils/collections/reports'; import PusherHelper from '../utils/PusherHelper'; import * as ReportTestUtils from '../utils/ReportTestUtils'; import * as TestHelper from '../utils/TestHelper'; @@ -56,6 +58,7 @@ jest.mock('../../src/hooks/useEnvironment', () => jest.mock('../../src/libs/Permissions', () => ({ canUseLinkPreviews: jest.fn(() => true), + canUseDefaultRooms: jest.fn(() => true), })); jest.mock('../../src/hooks/usePermissions.ts'); @@ -103,32 +106,17 @@ afterEach(() => { PusherHelper.teardown(); }); -/** - * This is a helper function to create a mock for the addListener function of the react-navigation library. - * The reason we need this is because we need to trigger the transitionEnd event in our tests to simulate - * the transitionEnd event that is triggered when the screen transition animation is completed. - * - * P.S: This can't be moved to a utils file because Jest wants any external function to stay in the scope. - * - * @returns {Object} An object with two functions: triggerTransitionEnd and addListener - */ -const createAddListenerMock = () => { - const transitionEndListeners = []; - const triggerTransitionEnd = () => { - transitionEndListeners.forEach((transitionEndListener) => transitionEndListener()); - }; - - const addListener = jest.fn().mockImplementation((listener, callback) => { - if (listener === 'transitionEnd') { - transitionEndListeners.push(callback); - } - return () => { - _.filter(transitionEndListeners, (cb) => cb !== callback); - }; - }); +const policies = createCollection( + (item) => `${ONYXKEYS.COLLECTION.POLICY}${item.id}`, + (index) => createRandomPolicy(index), + 10, +); - return {triggerTransitionEnd, addListener}; -}; +const personalDetails = createCollection( + (item) => item.accountID, + (index) => createPersonalDetails(index), + 20, +); function ReportScreenWrapper(args) { return ( @@ -152,8 +140,12 @@ function ReportScreenWrapper(args) { ); } -test.skip('[ReportScreen] should render ReportScreen with composer interactions', () => { - const {triggerTransitionEnd, addListener} = createAddListenerMock(); +const report = {...createRandomReport(1), policyID: '1'}; +const reportActions = ReportTestUtils.getMockedReportActionsMap(500); +const mockRoute = {params: {reportID: '1'}}; + +test('[ReportScreen] should render ReportScreen with composer interactions', () => { + const {triggerTransitionEnd, addListener} = TestHelper.createAddListenerMock(); const scenario = async () => { /** * First make sure ReportScreen is mounted, so that we can trigger @@ -163,11 +155,7 @@ test.skip('[ReportScreen] should render ReportScreen with composer interactions' * before the ReportScreen is mounted, and the test will fail. */ await screen.findByTestId('ReportScreen'); - - await act(triggerTransitionEnd); - - // Query for the report list - await screen.findByTestId('report-actions-list'); + await waitFor(triggerTransitionEnd); // Query for the composer const composer = await screen.findByTestId('composer'); @@ -189,15 +177,6 @@ test.skip('[ReportScreen] should render ReportScreen with composer interactions' await screen.findByLabelText(hintHeaderText); }; - const policy = { - policyID: 1, - name: 'Testing Policy', - }; - - const report = LHNTestUtils.getFakeReport(); - const reportActions = ReportTestUtils.getMockedReportActionsMap(1000); - const mockRoute = {params: {reportID: '1'}}; - const navigation = {addListener}; return waitForBatchedUpdates() @@ -206,12 +185,10 @@ test.skip('[ReportScreen] should render ReportScreen with composer interactions' [ONYXKEYS.IS_SIDEBAR_LOADED]: true, [`${ONYXKEYS.COLLECTION.REPORT}${mockRoute.params.reportID}`]: report, [`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${mockRoute.params.reportID}`]: reportActions, - [ONYXKEYS.PERSONAL_DETAILS_LIST]: LHNTestUtils.fakePersonalDetails, + [ONYXKEYS.PERSONAL_DETAILS_LIST]: personalDetails, [ONYXKEYS.BETAS]: [CONST.BETAS.DEFAULT_ROOMS], - [`${ONYXKEYS.COLLECTION.POLICY}${policy.policyID}`]: policy, - [`${ONYXKEYS.COLLECTION.REPORT_METADATA}${mockRoute.params.reportID}`]: { - isLoadingReportActions: false, - }, + [`${ONYXKEYS.COLLECTION.POLICY}`]: policies, + [ONYXKEYS.SHOULD_SHOW_COMPOSE_INPUT]: true, }), ) .then(() => @@ -225,8 +202,8 @@ test.skip('[ReportScreen] should render ReportScreen with composer interactions' ); }); -test.skip('[ReportScreen] should press of the report item', () => { - const {triggerTransitionEnd, addListener} = createAddListenerMock(); +test('[ReportScreen] should press of the report item', () => { + const {triggerTransitionEnd, addListener} = TestHelper.createAddListenerMock(); const scenario = async () => { /** * First make sure ReportScreen is mounted, so that we can trigger @@ -237,32 +214,19 @@ test.skip('[ReportScreen] should press of the report item', () => { */ await screen.findByTestId('ReportScreen'); - await act(triggerTransitionEnd); + await waitFor(triggerTransitionEnd); // Query for the report list await screen.findByTestId('report-actions-list'); - // Query for the composer - await screen.findByTestId('composer'); - - const hintReportPreviewText = Localize.translateLocal('iou.viewDetails'); - - // Query for report preview buttons - const reportPreviewButtons = await screen.findAllByLabelText(hintReportPreviewText); + const hintText = Localize.translateLocal('accessibilityHints.chatMessage'); - // click on the report preview button - fireEvent.press(reportPreviewButtons[0]); - }; + // Query for the list of items + const reportItems = await screen.findAllByLabelText(hintText); - const policy = { - policyID: 123, - name: 'Testing Policy', + fireEvent.press(reportItems[0], 'onLongPress'); }; - const report = LHNTestUtils.getFakeReport(); - const reportActions = ReportTestUtils.getMockedReportActionsMap(1000); - const mockRoute = {params: {reportID: '2'}}; - const navigation = {addListener}; return waitForBatchedUpdates() @@ -271,12 +235,9 @@ test.skip('[ReportScreen] should press of the report item', () => { [ONYXKEYS.IS_SIDEBAR_LOADED]: true, [`${ONYXKEYS.COLLECTION.REPORT}${mockRoute.params.reportID}`]: report, [`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${mockRoute.params.reportID}`]: reportActions, - [ONYXKEYS.PERSONAL_DETAILS_LIST]: LHNTestUtils.fakePersonalDetails, + [ONYXKEYS.PERSONAL_DETAILS_LIST]: personalDetails, [ONYXKEYS.BETAS]: [CONST.BETAS.DEFAULT_ROOMS], - [`${ONYXKEYS.COLLECTION.POLICY}${policy.policyID}`]: policy, - [`${ONYXKEYS.COLLECTION.REPORT_METADATA}${mockRoute.params.reportID}`]: { - isLoadingReportActions: false, - }, + [`${ONYXKEYS.COLLECTION.POLICY}`]: policies, }), ) .then(() => diff --git a/tests/unit/NextStepUtilsTest.ts b/tests/unit/NextStepUtilsTest.ts new file mode 100644 index 000000000000..568c641d2ac5 --- /dev/null +++ b/tests/unit/NextStepUtilsTest.ts @@ -0,0 +1,549 @@ +import {format, lastDayOfMonth, setDate} from 'date-fns'; +import Onyx from 'react-native-onyx'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {Policy, Report, ReportNextStep} from '@src/types/onyx'; +import DateUtils from '../../src/libs/DateUtils'; +import * as NextStepUtils from '../../src/libs/NextStepUtils'; +import * as ReportUtils from '../../src/libs/ReportUtils'; +import waitForBatchedUpdates from '../utils/waitForBatchedUpdates'; + +Onyx.init({keys: ONYXKEYS}); + +describe('libs/NextStepUtils', () => { + describe('buildNextStep', () => { + const currentUserEmail = 'current-user@expensify.com'; + const currentUserAccountID = 37; + const strangeEmail = 'stranger@expensify.com'; + const strangeAccountID = 50; + const policyID = '1'; + const policy: Policy = { + // Important props + id: policyID, + owner: currentUserEmail, + submitsTo: currentUserAccountID, + harvesting: { + enabled: false, + }, + // Required props + name: 'Policy', + role: 'admin', + type: 'team', + outputCurrency: CONST.CURRENCY.USD, + isPolicyExpenseChatEnabled: true, + }; + const optimisticNextStep: ReportNextStep = { + type: 'neutral', + title: '', + message: [], + }; + const report = ReportUtils.buildOptimisticExpenseReport('fake-chat-report-id-1', policyID, 1, -500, CONST.CURRENCY.USD) as Report; + + beforeAll(() => { + // @ts-expect-error Preset necessary values + Onyx.multiSet({ + [ONYXKEYS.SESSION]: {email: currentUserEmail, accountID: currentUserAccountID}, + [`${ONYXKEYS.COLLECTION.POLICY}${policyID}`]: policy, + [ONYXKEYS.PERSONAL_DETAILS_LIST]: { + [strangeAccountID]: { + accountID: strangeAccountID, + login: strangeEmail, + avatar: '', + }, + }, + }).then(waitForBatchedUpdates); + }); + + beforeEach(() => { + report.ownerAccountID = currentUserAccountID; + report.managerID = currentUserAccountID; + optimisticNextStep.title = ''; + optimisticNextStep.message = []; + + Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, policy).then(waitForBatchedUpdates); + }); + + describe('it generates an optimistic nextStep once a report has been opened', () => { + test('self review', () => { + optimisticNextStep.title = 'Next Steps:'; + optimisticNextStep.message = [ + { + text: 'Waiting for ', + }, + { + text: 'you', + type: 'strong', + }, + { + text: ' to ', + }, + { + text: 'submit', + type: 'strong', + }, + { + text: ' these expenses.', + }, + ]; + + const result = NextStepUtils.buildNextStep(report, CONST.REPORT.STATUS_NUM.OPEN); + + expect(result).toMatchObject(optimisticNextStep); + }); + + describe('scheduled submit enabled', () => { + beforeEach(() => { + optimisticNextStep.title = 'Next Steps:'; + }); + + test('daily', () => { + optimisticNextStep.message = [ + { + text: 'These expenses are scheduled to ', + }, + { + text: 'automatically submit later today!', + type: 'strong', + }, + { + text: ' No further action required!', + }, + ]; + + return Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, { + autoReportingFrequency: CONST.POLICY.AUTO_REPORTING_FREQUENCIES.IMMEDIATE, + harvesting: { + enabled: true, + }, + }).then(() => { + const result = NextStepUtils.buildNextStep(report, CONST.REPORT.STATUS_NUM.OPEN); + + expect(result).toMatchObject(optimisticNextStep); + }); + }); + + test('weekly', () => { + optimisticNextStep.message = [ + { + text: 'These expenses are scheduled to ', + }, + { + text: 'automatically submit on Sunday!', + type: 'strong', + }, + { + text: ' No further action required!', + }, + ]; + + return Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, { + autoReportingFrequency: CONST.POLICY.AUTO_REPORTING_FREQUENCIES.WEEKLY, + harvesting: { + enabled: true, + }, + }).then(() => { + const result = NextStepUtils.buildNextStep(report, CONST.REPORT.STATUS_NUM.OPEN); + + expect(result).toMatchObject(optimisticNextStep); + }); + }); + + test('twice a month', () => { + optimisticNextStep.message = [ + { + text: 'These expenses are scheduled to ', + }, + { + text: 'automatically submit on the 1st and 16th of each month!', + type: 'strong', + }, + { + text: ' No further action required!', + }, + ]; + + return Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, { + autoReportingFrequency: CONST.POLICY.AUTO_REPORTING_FREQUENCIES.SEMI_MONTHLY, + harvesting: { + enabled: true, + }, + }).then(() => { + const result = NextStepUtils.buildNextStep(report, CONST.REPORT.STATUS_NUM.OPEN); + + expect(result).toMatchObject(optimisticNextStep); + }); + }); + + test('monthly on the 2nd', () => { + optimisticNextStep.message = [ + { + text: 'These expenses are scheduled to ', + }, + { + text: 'automatically submit on the 2nd of each month!', + type: 'strong', + }, + { + text: ' No further action required!', + }, + ]; + + return Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, { + autoReportingFrequency: CONST.POLICY.AUTO_REPORTING_FREQUENCIES.MONTHLY, + autoReportingOffset: 2, + harvesting: { + enabled: true, + }, + }).then(() => { + const result = NextStepUtils.buildNextStep(report, CONST.REPORT.STATUS_NUM.OPEN); + + expect(result).toMatchObject(optimisticNextStep); + }); + }); + + test('monthly on the last day', () => { + optimisticNextStep.message = [ + { + text: 'These expenses are scheduled to ', + }, + { + text: `automatically submit on the ${format(lastDayOfMonth(new Date()), CONST.DATE.ORDINAL_DAY_OF_MONTH)} of each month!`, + type: 'strong', + }, + { + text: ' No further action required!', + }, + ]; + + return Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, { + autoReportingFrequency: CONST.POLICY.AUTO_REPORTING_FREQUENCIES.MONTHLY, + autoReportingOffset: CONST.POLICY.AUTO_REPORTING_OFFSET.LAST_DAY_OF_MONTH, + harvesting: { + enabled: true, + }, + }).then(() => { + const result = NextStepUtils.buildNextStep(report, CONST.REPORT.STATUS_NUM.OPEN); + + expect(result).toMatchObject(optimisticNextStep); + }); + }); + + test('monthly on the last business day', () => { + const lastBusinessDayOfMonth = DateUtils.getLastBusinessDayOfMonth(new Date()); + optimisticNextStep.message = [ + { + text: 'These expenses are scheduled to ', + }, + { + text: `automatically submit on the ${format(setDate(new Date(), lastBusinessDayOfMonth), CONST.DATE.ORDINAL_DAY_OF_MONTH)} of each month!`, + type: 'strong', + }, + { + text: ' No further action required!', + }, + ]; + + return Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, { + autoReportingFrequency: CONST.POLICY.AUTO_REPORTING_FREQUENCIES.MONTHLY, + autoReportingOffset: CONST.POLICY.AUTO_REPORTING_OFFSET.LAST_BUSINESS_DAY_OF_MONTH, + harvesting: { + enabled: true, + }, + }).then(() => { + const result = NextStepUtils.buildNextStep(report, CONST.REPORT.STATUS_NUM.OPEN); + + expect(result).toMatchObject(optimisticNextStep); + }); + }); + + test('trip', () => { + optimisticNextStep.message = [ + { + text: 'These expenses are scheduled to ', + }, + { + text: 'automatically submit at the end of your trip!', + type: 'strong', + }, + { + text: ' No further action required!', + }, + ]; + + return Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, { + autoReportingFrequency: CONST.POLICY.AUTO_REPORTING_FREQUENCIES.TRIP, + harvesting: { + enabled: true, + }, + }).then(() => { + const result = NextStepUtils.buildNextStep(report, CONST.REPORT.STATUS_NUM.OPEN); + + expect(result).toMatchObject(optimisticNextStep); + }); + }); + + test('manual', () => { + optimisticNextStep.message = [ + { + text: 'Waiting for ', + }, + { + text: 'you', + type: 'strong', + }, + { + text: ' to ', + }, + { + text: 'submit', + type: 'strong', + }, + { + text: ' these expenses.', + }, + ]; + + return Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, { + autoReportingFrequency: CONST.POLICY.AUTO_REPORTING_FREQUENCIES.MANUAL, + harvesting: { + enabled: true, + }, + }).then(() => { + const result = NextStepUtils.buildNextStep(report, CONST.REPORT.STATUS_NUM.OPEN); + + expect(result).toMatchObject(optimisticNextStep); + }); + }); + }); + + test('prevented self submitting', () => { + optimisticNextStep.title = 'Next Steps:'; + optimisticNextStep.message = [ + { + text: "Oops! Looks like you're submitting to ", + }, + { + text: 'yourself', + type: 'strong', + }, + { + text: '. Approving your own reports is ', + }, + { + text: 'forbidden', + type: 'strong', + }, + { + text: ' by your policy. Please submit this report to someone else or contact your admin to change the person you submit to.', + }, + ]; + + return Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, { + submitsTo: currentUserAccountID, + isPreventSelfApprovalEnabled: true, + }).then(() => { + const result = NextStepUtils.buildNextStep(report, CONST.REPORT.STATUS_NUM.OPEN); + + expect(result).toMatchObject(optimisticNextStep); + }); + }); + }); + + describe('it generates an optimistic nextStep once a report has been submitted', () => { + test('self review', () => { + optimisticNextStep.title = 'Next Steps:'; + optimisticNextStep.message = [ + { + text: 'Waiting for ', + }, + { + text: 'you', + type: 'strong', + }, + { + text: ' to ', + }, + { + text: 'review', + type: 'strong', + }, + { + text: ' %expenses.', + }, + ]; + + const result = NextStepUtils.buildNextStep(report, CONST.REPORT.STATUS_NUM.APPROVED); + + expect(result).toMatchObject(optimisticNextStep); + }); + + test('another reviewer', () => { + report.managerID = strangeAccountID; + optimisticNextStep.title = 'Next Steps:'; + optimisticNextStep.message = [ + { + text: 'Waiting for ', + }, + { + text: strangeEmail, + type: 'strong', + }, + { + text: ' to ', + }, + { + text: 'approve', + type: 'strong', + }, + { + text: ' %expenses.', + }, + ]; + + return Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, { + submitsTo: strangeAccountID, + }).then(() => { + const result = NextStepUtils.buildNextStep(report, CONST.REPORT.STATUS_NUM.SUBMITTED); + + expect(result).toMatchObject(optimisticNextStep); + }); + }); + + test('another owner', () => { + report.ownerAccountID = strangeAccountID; + optimisticNextStep.title = 'Next Steps:'; + optimisticNextStep.message = [ + { + text: strangeEmail, + type: 'strong', + }, + { + text: ' is waiting for ', + }, + { + text: 'you', + type: 'strong', + }, + { + text: ' to ', + }, + { + text: 'review', + type: 'strong', + }, + { + text: ' these %expenses.', + }, + ]; + + const result = NextStepUtils.buildNextStep(report, CONST.REPORT.STATUS_NUM.SUBMITTED); + + expect(result).toMatchObject(optimisticNextStep); + }); + }); + + describe('it generates an optimistic nextStep once a report has been approved', () => { + test('self review', () => { + optimisticNextStep.title = 'Next Steps:'; + optimisticNextStep.message = [ + { + text: 'Waiting for ', + }, + { + text: 'you', + type: 'strong', + }, + { + text: ' to ', + }, + { + text: 'review', + type: 'strong', + }, + { + text: ' %expenses.', + }, + ]; + + const result = NextStepUtils.buildNextStep(report, CONST.REPORT.STATUS_NUM.APPROVED); + + expect(result).toMatchObject(optimisticNextStep); + }); + + test('another owner', () => { + report.ownerAccountID = strangeAccountID; + optimisticNextStep.title = 'Finished!'; + optimisticNextStep.message = [ + { + text: 'No further action required!', + }, + ]; + + const result = NextStepUtils.buildNextStep(report, CONST.REPORT.STATUS_NUM.APPROVED); + + expect(result).toMatchObject(optimisticNextStep); + }); + }); + + describe('it generates an optimistic nextStep once a report has been paid', () => { + test('paid with wallet', () => { + optimisticNextStep.title = 'Finished!'; + optimisticNextStep.message = [ + { + text: 'You', + type: 'strong', + }, + { + text: ' have marked these expenses as ', + }, + { + text: 'paid', + type: 'strong', + }, + { + text: '.', + }, + ]; + + const result = NextStepUtils.buildNextStep(report, CONST.REPORT.STATUS_NUM.REIMBURSED, {isPaidWithWallet: true}); + + expect(result).toMatchObject(optimisticNextStep); + }); + + test('paid outside of Expensify', () => { + optimisticNextStep.title = 'Finished!'; + optimisticNextStep.message = [ + { + text: 'You', + type: 'strong', + }, + { + text: ' have marked these expenses as ', + }, + { + text: 'paid', + type: 'strong', + }, + { + text: ' outside of Expensify', + }, + { + text: '.', + }, + ]; + + const result = NextStepUtils.buildNextStep(report, CONST.REPORT.STATUS_NUM.REIMBURSED, {isPaidWithWallet: false}); + + expect(result).toMatchObject(optimisticNextStep); + }); + }); + + describe('it generates a nullable optimistic nextStep', () => { + test('closed status', () => { + const result = NextStepUtils.buildNextStep(report, CONST.REPORT.STATUS_NUM.CLOSED); + + expect(result).toBeNull(); + }); + }); + }); +}); diff --git a/tests/utils/ReportTestUtils.js b/tests/utils/ReportTestUtils.js index 910f2200876b..86899e4045f6 100644 --- a/tests/utils/ReportTestUtils.js +++ b/tests/utils/ReportTestUtils.js @@ -1,4 +1,5 @@ import _ from 'underscore'; +import createRandomReportAction from './collections/reportActions'; const actionNames = ['ADDCOMMENT', 'IOU', 'REPORTPREVIEW', 'CLOSED']; @@ -51,7 +52,13 @@ const getMockedReportActionsMap = (length = 100) => { const mockReports = Array.from({length}, (__, i) => { const reportID = i + 1; const actionName = i === 0 ? 'CREATED' : actionNames[i % actionNames.length]; - const reportAction = getFakeReportAction(reportID, actionName); + const reportAction = { + ...createRandomReportAction(reportID), + actionName, + originalMessage: { + linkedReportID: reportID.toString(), + }, + }; return {[reportID]: reportAction}; }); diff --git a/tests/utils/TestHelper.js b/tests/utils/TestHelper.js index dd95ab4efb67..9059041afd19 100644 --- a/tests/utils/TestHelper.js +++ b/tests/utils/TestHelper.js @@ -218,6 +218,8 @@ function assertFormDataMatchesObject(formData, obj) { /** * This is a helper function to create a mock for the addListener function of the react-navigation library. + * The reason we need this is because we need to trigger the transitionEnd event in our tests to simulate + * the transitionEnd event that is triggered when the screen transition animation is completed. * * @returns {Object} An object with two functions: triggerTransitionEnd and addListener */ diff --git a/tests/utils/collections/reportActions.ts b/tests/utils/collections/reportActions.ts index cc258e89c041..bb14a2c7a41b 100644 --- a/tests/utils/collections/reportActions.ts +++ b/tests/utils/collections/reportActions.ts @@ -1,4 +1,5 @@ -import {rand, randAggregation, randBoolean, randPastDate, randWord} from '@ngneat/falso'; +import {rand, randAggregation, randBoolean, randWord} from '@ngneat/falso'; +import {format} from 'date-fns'; import CONST from '@src/CONST'; import type {ReportAction} from '@src/types/onyx'; @@ -17,6 +18,15 @@ const flattenActionNamesValues = (actionNames: any) => { return result; }; +const getRandomDate = (): string => { + const randomTimestamp = Math.random() * new Date().getTime(); + const randomDate = new Date(randomTimestamp); + + const formattedDate = format(randomDate, CONST.DATE.FNS_DB_FORMAT_STRING); + + return formattedDate; +}; + export default function createRandomReportAction(index: number): ReportAction { return { // we need to add any here because of the way we are generating random values @@ -32,7 +42,7 @@ export default function createRandomReportAction(index: number): ReportAction { text: randWord(), }, ], - created: randPastDate().toISOString(), + created: getRandomDate(), message: [ { type: randWord(), @@ -57,13 +67,13 @@ export default function createRandomReportAction(index: number): ReportAction { ], originalMessage: { html: randWord(), - type: rand(Object.values(CONST.IOU.REPORT_ACTION_TYPE)), + lastModified: getRandomDate(), }, whisperedToAccountIDs: randAggregation(), avatar: randWord(), automatic: randBoolean(), shouldShow: randBoolean(), - lastModified: randPastDate().toISOString(), + lastModified: getRandomDate(), pendingAction: rand(Object.values(CONST.RED_BRICK_ROAD_PENDING_ACTION)), delegateAccountID: index, errors: {},