diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts index b46d3db8b60d..68e23fae2f9f 100755 --- a/src/ONYXKEYS.ts +++ b/src/ONYXKEYS.ts @@ -63,6 +63,14 @@ const ONYXKEYS = { /** Contains all the info for Tasks */ TASK: 'task', + /** + * Contains all the info for Workspace Rate and Unit while editing. + * + * Note: This is not under the COLLECTION key as we can edit rate and unit + * for one workspace only at a time. And we don't need to store + * rates and units for different workspaces at the same time. */ + WORKSPACE_RATE_AND_UNIT: 'workspaceRateAndUnit', + /** Contains a list of all currencies available to the user - user can * select a currency based on the list */ CURRENCY_LIST: 'currencyList', @@ -393,6 +401,7 @@ type OnyxValues = { [ONYXKEYS.PERSONAL_DETAILS_LIST]: OnyxTypes.PersonalDetailsList; [ONYXKEYS.PRIVATE_PERSONAL_DETAILS]: OnyxTypes.PrivatePersonalDetails; [ONYXKEYS.TASK]: OnyxTypes.Task; + [ONYXKEYS.WORKSPACE_RATE_AND_UNIT]: OnyxTypes.WorkspaceRateAndUnit; [ONYXKEYS.CURRENCY_LIST]: Record; [ONYXKEYS.UPDATE_AVAILABLE]: boolean; [ONYXKEYS.SCREEN_SHARE_REQUEST]: OnyxTypes.ScreenShareRequest; diff --git a/src/ROUTES.ts b/src/ROUTES.ts index 5a2ab8cfc7de..1c6abd2e4eef 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -468,6 +468,14 @@ const ROUTES = { route: 'workspace/:policyID/rateandunit', getRoute: (policyID: string) => `workspace/${policyID}/rateandunit` as const, }, + WORKSPACE_RATE_AND_UNIT_RATE: { + route: 'workspace/:policyID/rateandunit/rate', + getRoute: (policyID: string) => `workspace/${policyID}/rateandunit/rate` as const, + }, + WORKSPACE_RATE_AND_UNIT_UNIT: { + route: 'workspace/:policyID/rateandunit/unit', + getRoute: (policyID: string) => `workspace/${policyID}/rateandunit/unit` as const, + }, WORKSPACE_BILLS: { route: 'workspace/:policyID/bills', getRoute: (policyID: string) => `workspace/${policyID}/bills` as const, diff --git a/src/SCREENS.ts b/src/SCREENS.ts index 1d7c77bf129c..5528242caa18 100644 --- a/src/SCREENS.ts +++ b/src/SCREENS.ts @@ -198,6 +198,8 @@ const SCREENS = { CARD: 'Workspace_Card', REIMBURSE: 'Workspace_Reimburse', RATE_AND_UNIT: 'Workspace_RateAndUnit', + RATE_AND_UNIT_RATE: 'Workspace_RateAndUnit_Rate', + RATE_AND_UNIT_UNIT: 'Workspace_RateAndUnit_Unit', BILLS: 'Workspace_Bills', INVOICES: 'Workspace_Invoices', TRAVEL: 'Workspace_Travel', diff --git a/src/components/AmountForm.tsx b/src/components/AmountForm.tsx new file mode 100644 index 000000000000..4214d804af06 --- /dev/null +++ b/src/components/AmountForm.tsx @@ -0,0 +1,241 @@ +import type {ForwardedRef} from 'react'; +import React, {forwardRef, useCallback, useEffect, useMemo, useRef, useState} from 'react'; +import {View} from 'react-native'; +import type {NativeSyntheticEvent, TextInput, TextInputSelectionChangeEventData} from 'react-native'; +import useLocalize from '@hooks/useLocalize'; +import useThemeStyles from '@hooks/useThemeStyles'; +import * as Browser from '@libs/Browser'; +import * as CurrencyUtils from '@libs/CurrencyUtils'; +import * as DeviceCapabilities from '@libs/DeviceCapabilities'; +import getOperatingSystem from '@libs/getOperatingSystem'; +import * as MoneyRequestUtils from '@libs/MoneyRequestUtils'; +import CONST from '@src/CONST'; +import BigNumberPad from './BigNumberPad'; +import FormHelpMessage from './FormHelpMessage'; +import TextInputWithCurrencySymbol from './TextInputWithCurrencySymbol'; + +type AmountFormProps = { + /** Amount supplied by the FormProvider */ + value?: string; + + /** Currency supplied by user */ + currency?: string; + + /** Tells how many extra decimal digits are allowed. Default is 0. */ + extraDecimals?: number; + + /** Error to display at the bottom of the component */ + errorText?: string; + + /** Callback to update the amount in the FormProvider */ + onInputChange?: (value: string) => void; + + /** Fired when back button pressed, navigates to currency selection page */ + onCurrencyButtonPress?: () => void; +}; + +/** + * Returns the new selection object based on the updated amount's length + */ +const getNewSelection = (oldSelection: {start: number; end: number}, prevLength: number, newLength: number) => { + const cursorPosition = oldSelection.end + (newLength - prevLength); + return {start: cursorPosition, end: cursorPosition}; +}; + +const AMOUNT_VIEW_ID = 'amountView'; +const NUM_PAD_CONTAINER_VIEW_ID = 'numPadContainerView'; +const NUM_PAD_VIEW_ID = 'numPadView'; + +function AmountForm( + {value: amount, currency = CONST.CURRENCY.USD, extraDecimals = 0, errorText, onInputChange, onCurrencyButtonPress}: AmountFormProps, + forwardedRef: ForwardedRef, +) { + const styles = useThemeStyles(); + const {toLocaleDigit, numberFormat} = useLocalize(); + + const textInput = useRef(null); + + const decimals = CurrencyUtils.getCurrencyDecimals(currency) + extraDecimals; + const currentAmount = useMemo(() => (typeof amount === 'string' ? amount : ''), [amount]); + + const [shouldUpdateSelection, setShouldUpdateSelection] = useState(true); + + const [selection, setSelection] = useState({ + start: currentAmount.length, + end: currentAmount.length, + }); + + const forwardDeletePressedRef = useRef(false); + + /** + * Event occurs when a user presses a mouse button over an DOM element. + */ + const focusTextInput = (event: React.MouseEvent, ids: string[]) => { + const relatedTargetId = (event.nativeEvent?.target as HTMLElement | null)?.id ?? ''; + if (!ids.includes(relatedTargetId)) { + return; + } + event.preventDefault(); + if (!textInput.current) { + return; + } + if (!textInput.current.isFocused()) { + textInput.current.focus(); + } + }; + + /** + * Sets the selection and the amount accordingly to the value passed to the input + * @param newAmount - Changed amount from user input + */ + const setNewAmount = useCallback( + (newAmount: string) => { + // Remove spaces from the newAmount value because Safari on iOS adds spaces when pasting a copied value + // More info: https://github.com/Expensify/App/issues/16974 + const newAmountWithoutSpaces = MoneyRequestUtils.stripSpacesFromAmount(newAmount); + // Use a shallow copy of selection to trigger setSelection + // More info: https://github.com/Expensify/App/issues/16385 + if (!MoneyRequestUtils.validateAmount(newAmountWithoutSpaces, decimals)) { + setSelection((prevSelection) => ({...prevSelection})); + return; + } + + const strippedAmount = MoneyRequestUtils.stripCommaFromAmount(newAmountWithoutSpaces); + const isForwardDelete = currentAmount.length > strippedAmount.length && forwardDeletePressedRef.current; + setSelection((prevSelection) => getNewSelection(prevSelection, isForwardDelete ? strippedAmount.length : currentAmount.length, strippedAmount.length)); + onInputChange?.(strippedAmount); + }, + [currentAmount, decimals, onInputChange], + ); + + // Modifies the amount to match the decimals for changed currency. + useEffect(() => { + // If the changed currency supports decimals, we can return + if (MoneyRequestUtils.validateAmount(currentAmount, decimals)) { + return; + } + + // If the changed currency doesn't support decimals, we can strip the decimals + setNewAmount(MoneyRequestUtils.stripDecimalsFromAmount(currentAmount)); + + // we want to update only when decimals change (setNewAmount also changes when decimals change). + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [decimals]); + + /** + * Update amount with number or Backspace pressed for BigNumberPad. + * Validate new amount with decimal number regex up to 6 digits and 2 decimal digit to enable Next button + */ + const updateAmountNumberPad = useCallback( + (key: string) => { + if (shouldUpdateSelection && !textInput.current?.isFocused()) { + textInput.current?.focus(); + } + // Backspace button is pressed + if (key === '<' || key === 'Backspace') { + if (currentAmount.length > 0) { + const selectionStart = selection.start === selection.end ? selection.start - 1 : selection.start; + const newAmount = `${currentAmount.substring(0, selectionStart)}${currentAmount.substring(selection.end)}`; + setNewAmount(MoneyRequestUtils.addLeadingZero(newAmount)); + } + return; + } + const newAmount = MoneyRequestUtils.addLeadingZero(`${currentAmount.substring(0, selection.start)}${key}${currentAmount.substring(selection.end)}`); + setNewAmount(newAmount); + }, + [currentAmount, selection, shouldUpdateSelection, setNewAmount], + ); + + /** + * Update long press value, to remove items pressing on < + * + * @param value - Changed text from user input + */ + const updateLongPressHandlerState = useCallback((value: boolean) => { + setShouldUpdateSelection(!value); + if (!value && !textInput.current?.isFocused()) { + textInput.current?.focus(); + } + }, []); + + /** + * Input handler to check for a forward-delete key (or keyboard shortcut) press. + */ + const textInputKeyPress = (event: NativeSyntheticEvent) => { + const key = event.nativeEvent.key.toLowerCase(); + if (Browser.isMobileSafari() && key === CONST.PLATFORM_SPECIFIC_KEYS.CTRL.DEFAULT) { + // Optimistically anticipate forward-delete on iOS Safari (in cases where the Mac Accessiblity keyboard is being + // used for input). If the Control-D shortcut doesn't get sent, the ref will still be reset on the next key press. + forwardDeletePressedRef.current = true; + return; + } + // Control-D on Mac is a keyboard shortcut for forward-delete. See https://support.apple.com/en-us/HT201236 for Mac keyboard shortcuts. + // Also check for the keyboard shortcut on iOS in cases where a hardware keyboard may be connected to the device. + const operatingSystem = getOperatingSystem() as string | null; + const allowedOS: string[] = [CONST.OS.MAC_OS, CONST.OS.IOS]; + forwardDeletePressedRef.current = key === 'delete' || (allowedOS.includes(operatingSystem ?? '') && event.nativeEvent.ctrlKey && key === 'd'); + }; + + const formattedAmount = MoneyRequestUtils.replaceAllDigits(currentAmount, toLocaleDigit); + const canUseTouchScreen = DeviceCapabilities.canUseTouchScreen(); + + return ( + <> + focusTextInput(event, [AMOUNT_VIEW_ID])} + style={[styles.moneyRequestAmountContainer, styles.flex1, styles.flexRow, styles.w100, styles.alignItemsCenter, styles.justifyContentCenter]} + > + { + if (typeof forwardedRef === 'function') { + forwardedRef(ref); + } else if (forwardedRef && 'current' in forwardedRef) { + // eslint-disable-next-line no-param-reassign + forwardedRef.current = ref; + } + textInput.current = ref; + }} + selectedCurrencyCode={currency} + selection={selection} + onSelectionChange={(e: NativeSyntheticEvent) => { + if (!shouldUpdateSelection) { + return; + } + setSelection(e.nativeEvent.selection); + }} + onKeyPress={textInputKeyPress} + /> + {!!errorText && ( + + )} + + {canUseTouchScreen ? ( + focusTextInput(event, [NUM_PAD_CONTAINER_VIEW_ID, NUM_PAD_VIEW_ID])} + style={[styles.w100, styles.justifyContentEnd, styles.pageWrapper, styles.pt0]} + id={NUM_PAD_CONTAINER_VIEW_ID} + > + + + ) : null} + + ); +} + +AmountForm.displayName = 'AmountForm'; + +export default forwardRef(AmountForm); diff --git a/src/components/BigNumberPad.tsx b/src/components/BigNumberPad.tsx index 80f7794d0ad3..de43bf548a98 100644 --- a/src/components/BigNumberPad.tsx +++ b/src/components/BigNumberPad.tsx @@ -17,7 +17,7 @@ type BigNumberPadProps = { id?: string; /** Whether long press is disabled */ - isLongPressDisabled: boolean; + isLongPressDisabled?: boolean; }; const padNumbers = [ diff --git a/src/components/Form/FormProvider.tsx b/src/components/Form/FormProvider.tsx index ba0f823fdbad..82a8d0870946 100644 --- a/src/components/Form/FormProvider.tsx +++ b/src/components/Form/FormProvider.tsx @@ -1,7 +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 {NativeSyntheticEvent, StyleProp, TextInputSubmitEditingEventData, ViewStyle} from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; import {withOnyx} from 'react-native-onyx'; import * as ValidationUtils from '@libs/ValidationUtils'; @@ -62,6 +62,12 @@ type FormProviderProps = FormProvider /** Should validate function be called when the value of the input is changed */ shouldValidateOnChange?: boolean; + + /** Styles that will be applied to the submit button only */ + submitButtonStyles?: StyleProp; + + /** Whether to apply flex to the submit button */ + submitFlexEnabled?: boolean; }; type FormRef = { diff --git a/src/components/Form/FormWrapper.tsx b/src/components/Form/FormWrapper.tsx index 074069ec3ea7..e8778cf25d58 100644 --- a/src/components/Form/FormWrapper.tsx +++ b/src/components/Form/FormWrapper.tsx @@ -28,6 +28,9 @@ type FormWrapperProps = ChildrenProps & /** Submit button styles */ submitButtonStyles?: StyleProp; + /** Whether to apply flex to the submit button */ + submitFlexEnabled?: boolean; + /** Server side errors keyed by microtime */ errors: Errors; @@ -49,6 +52,7 @@ function FormWrapper({ isSubmitButtonVisible = true, style, submitButtonStyles, + submitFlexEnabled = true, enabledWhenOffline, isSubmitActionDangerous = false, formID, @@ -109,7 +113,7 @@ function FormWrapper({ onSubmit={onSubmit} footerContent={footerContent} onFixTheErrorsLinkPressed={onFixTheErrorsLinkPressed} - containerStyles={[styles.mh0, styles.mt5, styles.flex1, submitButtonStyles]} + containerStyles={[styles.mh0, styles.mt5, submitFlexEnabled ? styles.flex1 : {}, submitButtonStyles]} enabledWhenOffline={enabledWhenOffline} isSubmitActionDangerous={isSubmitActionDangerous} disablePressOnEnter={disablePressOnEnter} @@ -134,6 +138,7 @@ function FormWrapper({ styles.mh0, styles.mt5, submitButtonStyles, + submitFlexEnabled, submitButtonText, shouldHideFixErrorsAlert, onFixTheErrorsLinkPressed, diff --git a/src/components/Form/types.ts b/src/components/Form/types.ts index 353a6927caf7..5ade522e6d7f 100644 --- a/src/components/Form/types.ts +++ b/src/components/Form/types.ts @@ -1,6 +1,7 @@ import type {ComponentProps, FocusEvent, Key, MutableRefObject, ReactNode, Ref} from 'react'; import type {GestureResponderEvent, NativeSyntheticEvent, StyleProp, TextInputFocusEventData, TextInputSubmitEditingEventData, ViewStyle} from 'react-native'; import type AddressSearch from '@components/AddressSearch'; +import type AmountForm from '@components/AmountForm'; import type AmountTextInput from '@components/AmountTextInput'; import type CheckboxWithLabel from '@components/CheckboxWithLabel'; import type Picker from '@components/Picker'; @@ -17,7 +18,7 @@ import type {BaseForm, FormValueType} from '@src/types/onyx/Form'; * TODO: Add remaining inputs here once these components are migrated to Typescript: * CountrySelector | StatePicker | DatePicker | EmojiPickerButtonDropdown | RoomNameInput | ValuePicker */ -type ValidInputs = typeof TextInput | typeof AmountTextInput | typeof SingleChoiceQuestion | typeof CheckboxWithLabel | typeof Picker | typeof AddressSearch; +type ValidInputs = typeof TextInput | typeof AmountTextInput | typeof SingleChoiceQuestion | typeof CheckboxWithLabel | typeof Picker | typeof AddressSearch | typeof AmountForm; type ValueTypeKey = 'string' | 'boolean' | 'date'; diff --git a/src/components/MenuItem.tsx b/src/components/MenuItem.tsx index 6163fa116561..faecf55a0c4a 100644 --- a/src/components/MenuItem.tsx +++ b/src/components/MenuItem.tsx @@ -57,7 +57,7 @@ type NoIcon = { icon?: undefined; }; -type MenuItemProps = (IconProps | AvatarProps | NoIcon) & { +type MenuItemBaseProps = { /** Function to fire when component is pressed */ onPress?: (event: GestureResponderEvent | KeyboardEvent) => void | Promise; @@ -233,6 +233,8 @@ type MenuItemProps = (IconProps | AvatarProps | NoIcon) & { isPaneMenu?: boolean; }; +type MenuItemProps = (IconProps | AvatarProps | NoIcon) & MenuItemBaseProps; + function MenuItem( { interactive = true, @@ -625,5 +627,5 @@ function MenuItem( MenuItem.displayName = 'MenuItem'; -export type {MenuItemProps}; +export type {IconProps, AvatarProps, NoIcon, MenuItemBaseProps, MenuItemProps}; export default forwardRef(MenuItem); diff --git a/src/languages/en.ts b/src/languages/en.ts index f24b0e3e2438..afd9de04a1b9 100755 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -1622,6 +1622,9 @@ export default { trackDistanceCopy: 'Set the per mile/km rate and choose a default unit to track.', trackDistanceRate: 'Rate', trackDistanceUnit: 'Unit', + trackDistanceChooseUnit: 'Choose a default unit to track.', + kilometers: 'Kilometers', + miles: 'Miles', unlockNextDayReimbursements: 'Unlock next-day reimbursements', captureNoVBACopyBeforeEmail: 'Ask your workspace members to forward receipts to ', captureNoVBACopyAfterEmail: ' and download the Expensify App to track cash expenses on the go.', diff --git a/src/languages/es.ts b/src/languages/es.ts index 3238e7fa94b0..2a0fa1d6dc35 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -1645,6 +1645,9 @@ export default { trackDistanceCopy: 'Configura la tarifa y unidad usadas para medir distancias.', trackDistanceRate: 'Tarifa', trackDistanceUnit: 'Unidad', + trackDistanceChooseUnit: 'Elija una unidad predeterminada para rastrear.', + kilometers: 'Kilómetros', + miles: 'Millas', unlockNextDayReimbursements: 'Desbloquea reembolsos diarios', captureNoVBACopyBeforeEmail: 'Pide a los miembros de tu espacio de trabajo que envíen recibos a ', captureNoVBACopyAfterEmail: ' y descarga la App de Expensify para controlar tus gastos en efectivo sobre la marcha.', diff --git a/src/libs/API/parameters/UpdateWorkspaceCustomUnitAndRateParams.ts b/src/libs/API/parameters/UpdateWorkspaceCustomUnitAndRateParams.ts index 22bbd20c7308..010bcaa1e60a 100644 --- a/src/libs/API/parameters/UpdateWorkspaceCustomUnitAndRateParams.ts +++ b/src/libs/API/parameters/UpdateWorkspaceCustomUnitAndRateParams.ts @@ -1,6 +1,6 @@ type UpdateWorkspaceCustomUnitAndRateParams = { policyID: string; - lastModified: number; + lastModified?: string; customUnit: string; customUnitRate: string; }; diff --git a/src/libs/CurrencyUtils.ts b/src/libs/CurrencyUtils.ts index cec9d1e09088..4098cbcd31fc 100644 --- a/src/libs/CurrencyUtils.ts +++ b/src/libs/CurrencyUtils.ts @@ -110,6 +110,21 @@ function convertToDisplayString(amountInCents = 0, currency: string = CONST.CURR }); } +/** + * Given an amount, convert it to a string for display in the UI. + * + * @param amount – should be a float. + * @param currency - IOU currency + */ +function convertAmountToDisplayString(amount = 0, currency: string = CONST.CURRENCY.USD): string { + const convertedAmount = amount / 100.0; + return NumberFormatUtils.format(BaseLocaleListener.getPreferredLocale(), convertedAmount, { + style: 'currency', + currency, + minimumFractionDigits: getCurrencyDecimals(currency) + 1, + }); +} + /** * Checks if passed currency code is a valid currency based on currency list */ @@ -127,5 +142,6 @@ export { convertToBackendAmount, convertToFrontendAmount, convertToDisplayString, + convertAmountToDisplayString, isValidCurrencyCode, }; diff --git a/src/libs/MoneyRequestUtils.ts b/src/libs/MoneyRequestUtils.ts index ff86070395b3..da8a5b843ec1 100644 --- a/src/libs/MoneyRequestUtils.ts +++ b/src/libs/MoneyRequestUtils.ts @@ -34,10 +34,10 @@ function addLeadingZero(amount: string): string { /** * Calculate the length of the amount with leading zeroes */ -function calculateAmountLength(amount: string): number { +function calculateAmountLength(amount: string, decimals: number): number { const leadingZeroes = amount.match(/^0+/); const leadingZeroesLength = leadingZeroes?.[0]?.length ?? 0; - const absAmount = parseFloat((Number(stripCommaFromAmount(amount)) * 100).toFixed(2)).toString(); + const absAmount = parseFloat((Number(stripCommaFromAmount(amount)) * 10 ** decimals).toFixed(2)).toString(); if (/\D/.test(absAmount)) { return CONST.IOU.AMOUNT_MAX_LENGTH + 1; @@ -55,7 +55,7 @@ function validateAmount(amount: string, decimals: number): boolean { ? `^\\d+(,\\d*)*$` // Don't allow decimal point if decimals === 0 : `^\\d+(,\\d*)*(\\.\\d{0,${decimals}})?$`; // Allow the decimal point and the desired number of digits after the point const decimalNumberRegex = new RegExp(regexString, 'i'); - return amount === '' || (decimalNumberRegex.test(amount) && calculateAmountLength(amount) <= CONST.IOU.AMOUNT_MAX_LENGTH); + return amount === '' || (decimalNumberRegex.test(amount) && calculateAmountLength(amount, decimals) <= CONST.IOU.AMOUNT_MAX_LENGTH); } /** diff --git a/src/libs/Navigation/AppNavigator/ModalStackNavigators.tsx b/src/libs/Navigation/AppNavigator/ModalStackNavigators.tsx index b0ec6d1f3a94..c87c696138c5 100644 --- a/src/libs/Navigation/AppNavigator/ModalStackNavigators.tsx +++ b/src/libs/Navigation/AppNavigator/ModalStackNavigators.tsx @@ -238,7 +238,9 @@ const SettingsModalStackNavigator = createModalStackNavigator require('../../../pages/settings/Profile/CustomStatus/StatusClearAfterPage').default as React.ComponentType, [SCREENS.SETTINGS.PROFILE.STATUS_CLEAR_AFTER_DATE]: () => require('../../../pages/settings/Profile/CustomStatus/SetDatePage').default as React.ComponentType, [SCREENS.SETTINGS.PROFILE.STATUS_CLEAR_AFTER_TIME]: () => require('../../../pages/settings/Profile/CustomStatus/SetTimePage').default as React.ComponentType, - [SCREENS.WORKSPACE.RATE_AND_UNIT]: () => require('../../../pages/workspace/reimburse/WorkspaceRateAndUnitPage').default as React.ComponentType, + [SCREENS.WORKSPACE.RATE_AND_UNIT]: () => require('../../../pages/workspace/reimburse/WorkspaceRateAndUnitPage/InitialPage').default as React.ComponentType, + [SCREENS.WORKSPACE.RATE_AND_UNIT_RATE]: () => require('../../../pages/workspace/reimburse/WorkspaceRateAndUnitPage/RatePage').default as React.ComponentType, + [SCREENS.WORKSPACE.RATE_AND_UNIT_UNIT]: () => require('../../../pages/workspace/reimburse/WorkspaceRateAndUnitPage/UnitPage').default as React.ComponentType, [SCREENS.WORKSPACE.INVITE]: () => require('../../../pages/workspace/WorkspaceInvitePage').default as React.ComponentType, [SCREENS.WORKSPACE.INVITE_MESSAGE]: () => require('../../../pages/workspace/WorkspaceInviteMessagePage').default as React.ComponentType, [SCREENS.WORKSPACE.NAME]: () => require('../../../pages/workspace/WorkspaceNamePage').default as React.ComponentType, 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 d96ad416832d..1936de564c37 100755 --- a/src/libs/Navigation/linkingConfig/CENTRAL_PANE_TO_RHP_MAPPING.ts +++ b/src/libs/Navigation/linkingConfig/CENTRAL_PANE_TO_RHP_MAPPING.ts @@ -3,7 +3,7 @@ import SCREENS from '@src/SCREENS'; const CENTRAL_PANE_TO_RHP_MAPPING: Partial> = { [SCREENS.WORKSPACE.PROFILE]: [SCREENS.WORKSPACE.NAME, SCREENS.WORKSPACE.CURRENCY], - [SCREENS.WORKSPACE.REIMBURSE]: [SCREENS.WORKSPACE.RATE_AND_UNIT], + [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], }; diff --git a/src/libs/Navigation/linkingConfig/config.ts b/src/libs/Navigation/linkingConfig/config.ts index ab218adc3879..25df1074731e 100644 --- a/src/libs/Navigation/linkingConfig/config.ts +++ b/src/libs/Navigation/linkingConfig/config.ts @@ -229,6 +229,12 @@ const config: LinkingOptions['config'] = { [SCREENS.WORKSPACE.RATE_AND_UNIT]: { path: ROUTES.WORKSPACE_RATE_AND_UNIT.route, }, + [SCREENS.WORKSPACE.RATE_AND_UNIT_RATE]: { + path: ROUTES.WORKSPACE_RATE_AND_UNIT_RATE.route, + }, + [SCREENS.WORKSPACE.RATE_AND_UNIT_UNIT]: { + path: ROUTES.WORKSPACE_RATE_AND_UNIT_UNIT.route, + }, [SCREENS.WORKSPACE.INVITE]: { path: ROUTES.WORKSPACE_INVITE.route, }, diff --git a/src/libs/Navigation/types.ts b/src/libs/Navigation/types.ts index bbdb03ab3df8..33230897bcf5 100644 --- a/src/libs/Navigation/types.ts +++ b/src/libs/Navigation/types.ts @@ -140,7 +140,15 @@ type SettingsNavigatorParamList = { [SCREENS.SETTINGS.PROFILE.STATUS_CLEAR_AFTER_TIME]: undefined; [SCREENS.WORKSPACE.CURRENCY]: undefined; [SCREENS.WORKSPACE.NAME]: undefined; - [SCREENS.WORKSPACE.RATE_AND_UNIT]: undefined; + [SCREENS.WORKSPACE.RATE_AND_UNIT]: { + policyID: string; + }; + [SCREENS.WORKSPACE.RATE_AND_UNIT_RATE]: { + policyID: string; + }; + [SCREENS.WORKSPACE.RATE_AND_UNIT_UNIT]: { + policyID: string; + }; [SCREENS.WORKSPACE.INVITE]: { policyID: string; }; diff --git a/src/libs/actions/Policy.ts b/src/libs/actions/Policy.ts index 498ce6918509..6c13d2153b6b 100644 --- a/src/libs/actions/Policy.ts +++ b/src/libs/actions/Policy.ts @@ -49,7 +49,7 @@ import type { Transaction, } from '@src/types/onyx'; import type {Errors} from '@src/types/onyx/OnyxCommon'; -import type {CustomUnit} from '@src/types/onyx/Policy'; +import type {Attributes, CustomUnit, Rate, Unit} from '@src/types/onyx/Policy'; import type {EmptyObject} from '@src/types/utils/EmptyObject'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; @@ -82,6 +82,13 @@ type OptimisticCustomUnits = { type PoliciesRecord = Record>; +type NewCustomUnit = { + customUnitID: string; + name: string; + attributes: Attributes; + rates: Rate; +}; + const allPolicies: OnyxCollection = {}; Onyx.connect({ key: ONYXKEYS.COLLECTION.POLICY, @@ -945,7 +952,7 @@ function hideWorkspaceAlertMessage(policyID: string) { Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, {alertMessage: ''}); } -function updateWorkspaceCustomUnitAndRate(policyID: string, currentCustomUnit: CustomUnit, newCustomUnit: CustomUnit, lastModified: number) { +function updateWorkspaceCustomUnitAndRate(policyID: string, currentCustomUnit: CustomUnit, newCustomUnit: NewCustomUnit, lastModified?: string) { if (!currentCustomUnit.customUnitID || !newCustomUnit?.customUnitID || !newCustomUnit.rates?.customUnitRateID) { return; } @@ -959,7 +966,7 @@ function updateWorkspaceCustomUnitAndRate(policyID: string, currentCustomUnit: C [newCustomUnit.customUnitID]: { ...newCustomUnit, rates: { - [newCustomUnit.rates.customUnitRateID as string]: { + [newCustomUnit.rates.customUnitRateID]: { ...newCustomUnit.rates, errors: null, pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, @@ -982,7 +989,7 @@ function updateWorkspaceCustomUnitAndRate(policyID: string, currentCustomUnit: C pendingAction: null, errors: null, rates: { - [newCustomUnit.rates.customUnitRateID as string]: { + [newCustomUnit.rates.customUnitRateID]: { pendingAction: null, }, }, @@ -1001,7 +1008,7 @@ function updateWorkspaceCustomUnitAndRate(policyID: string, currentCustomUnit: C [currentCustomUnit.customUnitID]: { customUnitID: currentCustomUnit.customUnitID, rates: { - [newCustomUnit.rates.customUnitRateID as string]: { + [newCustomUnit.rates.customUnitRateID]: { ...currentCustomUnit.rates, errors: ErrorUtils.getMicroSecondOnyxError('workspace.reimburse.updateCustomUnitError'), }, @@ -1460,6 +1467,22 @@ function openWorkspaceReimburseView(policyID: string) { API.read(READ_COMMANDS.OPEN_WORKSPACE_REIMBURSE_VIEW, params, {successData, failureData}); } +function setPolicyIDForReimburseView(policyID: string) { + Onyx.merge(ONYXKEYS.WORKSPACE_RATE_AND_UNIT, {policyID, rate: null, unit: null}); +} + +function clearOnyxDataForReimburseView() { + Onyx.merge(ONYXKEYS.WORKSPACE_RATE_AND_UNIT, null); +} + +function setRateForReimburseView(rate: string) { + Onyx.merge(ONYXKEYS.WORKSPACE_RATE_AND_UNIT, {rate}); +} + +function setUnitForReimburseView(unit: Unit) { + Onyx.merge(ONYXKEYS.WORKSPACE_RATE_AND_UNIT, {unit}); +} + /** * Returns the accountIDs of the members of the policy whose data is passed in the parameters */ @@ -2009,6 +2032,10 @@ export { clearAddMemberError, clearDeleteWorkspaceError, openWorkspaceReimburseView, + setPolicyIDForReimburseView, + clearOnyxDataForReimburseView, + setRateForReimburseView, + setUnitForReimburseView, generateDefaultWorkspaceName, updateGeneralSettings, clearWorkspaceGeneralSettingsErrors, diff --git a/src/pages/workspace/WorkspacePageWithSections.tsx b/src/pages/workspace/WorkspacePageWithSections.tsx index 70198f38f18c..621541125809 100644 --- a/src/pages/workspace/WorkspacePageWithSections.tsx +++ b/src/pages/workspace/WorkspacePageWithSections.tsx @@ -84,7 +84,7 @@ function fetchData(skipVBBACal?: boolean) { } function WorkspacePageWithSections({ - backButtonRoute = '', + backButtonRoute, children = () => null, footer = null, guidesCallTaskID = '', diff --git a/src/pages/workspace/reimburse/WorkspaceRateAndUnitPage.js b/src/pages/workspace/reimburse/WorkspaceRateAndUnitPage.js deleted file mode 100644 index 2ed09212233c..000000000000 --- a/src/pages/workspace/reimburse/WorkspaceRateAndUnitPage.js +++ /dev/null @@ -1,172 +0,0 @@ -import lodashGet from 'lodash/get'; -import React, {useEffect} from 'react'; -import {Keyboard, View} from 'react-native'; -import {withOnyx} from 'react-native-onyx'; -import _ from 'underscore'; -import FormProvider from '@components/Form/FormProvider'; -import InputWrapper from '@components/Form/InputWrapper'; -import OfflineWithFeedback from '@components/OfflineWithFeedback'; -import {withNetwork} from '@components/OnyxProvider'; -import Picker from '@components/Picker'; -import TextInput from '@components/TextInput'; -import withLocalize, {withLocalizePropTypes} from '@components/withLocalize'; -import withThemeStyles, {withThemeStylesPropTypes} from '@components/withThemeStyles'; -import compose from '@libs/compose'; -import * as CurrencyUtils from '@libs/CurrencyUtils'; -import getPermittedDecimalSeparator from '@libs/getPermittedDecimalSeparator'; -import Navigation from '@libs/Navigation/Navigation'; -import * as NumberUtils from '@libs/NumberUtils'; -import * as PolicyUtils from '@libs/PolicyUtils'; -import withPolicy, {policyDefaultProps, policyPropTypes} from '@pages/workspace/withPolicy'; -import WorkspacePageWithSections from '@pages/workspace/WorkspacePageWithSections'; -import * as BankAccounts from '@userActions/BankAccounts'; -import * as Policy from '@userActions/Policy'; -import CONST from '@src/CONST'; -import ONYXKEYS from '@src/ONYXKEYS'; - -const propTypes = { - ...policyPropTypes, - ...withLocalizePropTypes, - ...withThemeStylesPropTypes, -}; - -const defaultProps = { - reimbursementAccount: {}, - ...policyDefaultProps, -}; - -function WorkspaceRateAndUnitPage(props) { - useEffect(() => { - if (lodashGet(props, 'policy.customUnits', []).length !== 0) { - return; - } - - BankAccounts.setReimbursementAccountLoading(true); - Policy.openWorkspaceReimburseView(props.policy.id); - }, [props]); - - const unitItems = [ - {label: props.translate('common.kilometers'), value: CONST.CUSTOM_UNITS.DISTANCE_UNIT_KILOMETERS}, - {label: props.translate('common.miles'), value: CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES}, - ]; - - const saveUnitAndRate = (unit, rate) => { - const distanceCustomUnit = _.find(lodashGet(props, 'policy.customUnits', {}), (customUnit) => customUnit.name === CONST.CUSTOM_UNITS.NAME_DISTANCE); - if (!distanceCustomUnit) { - return; - } - const currentCustomUnitRate = _.find(lodashGet(distanceCustomUnit, 'rates', {}), (r) => r.name === CONST.CUSTOM_UNITS.DEFAULT_RATE); - const unitID = lodashGet(distanceCustomUnit, 'customUnitID', ''); - const unitName = lodashGet(distanceCustomUnit, 'name', ''); - const rateNumValue = PolicyUtils.getNumericValue(rate, props.toLocaleDigit); - - const newCustomUnit = { - customUnitID: unitID, - name: unitName, - attributes: {unit}, - rates: { - ...currentCustomUnitRate, - rate: rateNumValue * CONST.POLICY.CUSTOM_UNIT_RATE_BASE_OFFSET, - }, - }; - Policy.updateWorkspaceCustomUnitAndRate(props.policy.id, distanceCustomUnit, newCustomUnit, props.policy.lastModified); - }; - - const submit = (values) => { - saveUnitAndRate(values.unit, values.rate); - Keyboard.dismiss(); - Navigation.goBack(); - }; - - const validate = (values) => { - const errors = {}; - const decimalSeparator = props.toLocaleDigit('.'); - const outputCurrency = lodashGet(props, 'policy.outputCurrency', CONST.CURRENCY.USD); - // Allow one more decimal place for accuracy - const rateValueRegex = RegExp(String.raw`^-?\d{0,8}([${getPermittedDecimalSeparator(decimalSeparator)}]\d{1,${CurrencyUtils.getCurrencyDecimals(outputCurrency) + 1}})?$`, 'i'); - if (!rateValueRegex.test(values.rate) || values.rate === '') { - errors.rate = 'workspace.reimburse.invalidRateError'; - } else if (NumberUtils.parseFloatAnyLocale(values.rate) <= 0) { - errors.rate = 'workspace.reimburse.lowRateError'; - } - return errors; - }; - - const distanceCustomUnit = _.find(lodashGet(props, 'policy.customUnits', {}), (unit) => unit.name === CONST.CUSTOM_UNITS.NAME_DISTANCE); - const distanceCustomRate = _.find(lodashGet(distanceCustomUnit, 'rates', {}), (rate) => rate.name === CONST.CUSTOM_UNITS.DEFAULT_RATE); - - return ( - - {() => ( - - - Policy.clearCustomUnitErrors(props.policy.id, lodashGet(distanceCustomUnit, 'customUnitID', ''), lodashGet(distanceCustomRate, 'customUnitRateID', '')) - } - > - - - - - - - - )} - - ); -} - -WorkspaceRateAndUnitPage.propTypes = propTypes; -WorkspaceRateAndUnitPage.defaultProps = defaultProps; -WorkspaceRateAndUnitPage.displayName = 'WorkspaceRateAndUnitPage'; - -export default compose( - withPolicy, - withLocalize, - withNetwork(), - withOnyx({ - reimbursementAccount: { - key: ONYXKEYS.REIMBURSEMENT_ACCOUNT, - }, - }), - withThemeStyles, -)(WorkspaceRateAndUnitPage); diff --git a/src/pages/workspace/reimburse/WorkspaceRateAndUnitPage/InitialPage.tsx b/src/pages/workspace/reimburse/WorkspaceRateAndUnitPage/InitialPage.tsx new file mode 100644 index 000000000000..1cafc20e5c3f --- /dev/null +++ b/src/pages/workspace/reimburse/WorkspaceRateAndUnitPage/InitialPage.tsx @@ -0,0 +1,175 @@ +import React, {useEffect, useMemo} from 'react'; +import {ScrollView, View} from 'react-native'; +import {withOnyx} from 'react-native-onyx'; +import type {OnyxEntry} from 'react-native-onyx'; +import FormAlertWithSubmitButton from '@components/FormAlertWithSubmitButton'; +import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; +import OfflineWithFeedback from '@components/OfflineWithFeedback'; +import {withNetwork} from '@components/OnyxProvider'; +import useLocalize from '@hooks/useLocalize'; +import useThemeStyles from '@hooks/useThemeStyles'; +import compose from '@libs/compose'; +import * as CurrencyUtils from '@libs/CurrencyUtils'; +import Navigation from '@libs/Navigation/Navigation'; +import withPolicy from '@pages/workspace/withPolicy'; +import type {WithPolicyProps} from '@pages/workspace/withPolicy'; +import WorkspacePageWithSections from '@pages/workspace/WorkspacePageWithSections'; +import * as BankAccounts from '@userActions/BankAccounts'; +import * as Policy from '@userActions/Policy'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import ROUTES from '@src/ROUTES'; +import type {Network, ReimbursementAccount, WorkspaceRateAndUnit} from '@src/types/onyx'; +import type {Unit} from '@src/types/onyx/Policy'; +import {isEmptyObject} from '@src/types/utils/EmptyObject'; + +type WorkspaceRateAndUnitPageBaseProps = WithPolicyProps & { + // eslint-disable-next-line react/no-unused-prop-types + network: OnyxEntry; +}; + +type WorkspaceRateAndUnitOnyxProps = { + workspaceRateAndUnit: OnyxEntry; + // eslint-disable-next-line react/no-unused-prop-types + reimbursementAccount: OnyxEntry; +}; + +type WorkspaceRateAndUnitPageProps = WorkspaceRateAndUnitPageBaseProps & WorkspaceRateAndUnitOnyxProps; + +function WorkspaceRateAndUnitPage(props: WorkspaceRateAndUnitPageProps) { + const styles = useThemeStyles(); + const {translate} = useLocalize(); + + useEffect(() => { + if (props.workspaceRateAndUnit?.policyID === props.policy?.id) { + return; + } + Policy.setPolicyIDForReimburseView(props.policy?.id ?? ''); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + useEffect(() => { + const customUnits = props.policy?.customUnits ?? {}; + if (!isEmptyObject(customUnits)) { + return; + } + + BankAccounts.setReimbursementAccountLoading(true); + Policy.openWorkspaceReimburseView(props.policy?.id ?? ''); + }, [props]); + + const unitItems = useMemo( + () => ({ + [CONST.CUSTOM_UNITS.DISTANCE_UNIT_KILOMETERS]: translate('workspace.reimburse.kilometers'), + [CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES]: translate('workspace.reimburse.miles'), + }), + [translate], + ); + + const saveUnitAndRate = (newUnit: Unit, newRate: string) => { + const distanceCustomUnit = Object.values(props.policy?.customUnits ?? {}).find((unit) => unit.name === CONST.CUSTOM_UNITS.NAME_DISTANCE); + if (!distanceCustomUnit) { + return; + } + const currentCustomUnitRate = Object.values(distanceCustomUnit?.rates ?? {}).find((rate) => rate.name === CONST.CUSTOM_UNITS.DEFAULT_RATE); + const unitID = distanceCustomUnit.customUnitID ?? ''; + const unitName = distanceCustomUnit.name ?? ''; + + const newCustomUnit = { + customUnitID: unitID, + name: unitName, + attributes: {unit: newUnit}, + rates: { + ...currentCustomUnitRate, + rate: parseFloat(newRate), + }, + }; + Policy.updateWorkspaceCustomUnitAndRate(props.policy?.id ?? '', distanceCustomUnit, newCustomUnit, props.policy?.lastModified); + }; + + const distanceCustomUnit = Object.values(props.policy?.customUnits ?? {}).find((unit) => unit.name === CONST.CUSTOM_UNITS.NAME_DISTANCE); + const distanceCustomRate = Object.values(distanceCustomUnit?.rates ?? {}).find((rate) => rate.name === CONST.CUSTOM_UNITS.DEFAULT_RATE); + + const unitValue = props.workspaceRateAndUnit?.unit ?? distanceCustomUnit?.attributes.unit ?? CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES; + const rateValue = props.workspaceRateAndUnit?.rate ?? distanceCustomRate?.rate?.toString() ?? ''; + + const submit = () => { + saveUnitAndRate(unitValue, rateValue); + Policy.clearOnyxDataForReimburseView(); + Navigation.goBack(); + }; + + return ( + + {() => ( + + + + Policy.clearCustomUnitErrors(props.policy?.id ?? '', distanceCustomUnit?.customUnitID ?? '', distanceCustomRate?.customUnitRateID ?? '')} + > + Navigation.navigate(ROUTES.WORKSPACE_RATE_AND_UNIT_RATE.getRoute(props.policy?.id ?? ''))} + shouldShowRightIcon + /> + Navigation.navigate(ROUTES.WORKSPACE_RATE_AND_UNIT_UNIT.getRoute(props.policy?.id ?? ''))} + shouldShowRightIcon + /> + + + + + submit()} + enabledWhenOffline + buttonText={translate('common.save')} + containerStyles={[styles.mh0, styles.mt5, styles.flex1, styles.ph5]} + isAlertVisible={false} + /> + + + )} + + ); +} + +WorkspaceRateAndUnitPage.displayName = 'WorkspaceRateAndUnitPage'; + +export default compose( + withOnyx({ + reimbursementAccount: { + key: ONYXKEYS.REIMBURSEMENT_ACCOUNT, + }, + workspaceRateAndUnit: { + key: ONYXKEYS.WORKSPACE_RATE_AND_UNIT, + }, + }), + withPolicy, + withNetwork(), +)(WorkspaceRateAndUnitPage); diff --git a/src/pages/workspace/reimburse/WorkspaceRateAndUnitPage/RatePage.tsx b/src/pages/workspace/reimburse/WorkspaceRateAndUnitPage/RatePage.tsx new file mode 100644 index 000000000000..37723fe654c0 --- /dev/null +++ b/src/pages/workspace/reimburse/WorkspaceRateAndUnitPage/RatePage.tsx @@ -0,0 +1,119 @@ +import React, {useEffect, useMemo} from 'react'; +import {withOnyx} from 'react-native-onyx'; +import type {OnyxEntry} from 'react-native-onyx'; +import AmountForm from '@components/AmountForm'; +import FormProvider from '@components/Form/FormProvider'; +import InputWrapperWithRef from '@components/Form/InputWrapper'; +import type {OnyxFormValuesFields} from '@components/Form/types'; +import useLocalize from '@hooks/useLocalize'; +import useThemeStyles from '@hooks/useThemeStyles'; +import compose from '@libs/compose'; +import * as CurrencyUtils from '@libs/CurrencyUtils'; +import getPermittedDecimalSeparator from '@libs/getPermittedDecimalSeparator'; +import * as MoneyRequestUtils from '@libs/MoneyRequestUtils'; +import Navigation from '@libs/Navigation/Navigation'; +import * as NumberUtils from '@libs/NumberUtils'; +import withPolicy from '@pages/workspace/withPolicy'; +import type {WithPolicyProps} from '@pages/workspace/withPolicy'; +import WorkspacePageWithSections from '@pages/workspace/WorkspacePageWithSections'; +import * as Policy from '@userActions/Policy'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import ROUTES from '@src/ROUTES'; +import type {WorkspaceRateAndUnit} from '@src/types/onyx'; + +type WorkspaceRatePageBaseProps = WithPolicyProps; + +type WorkspaceRateAndUnitOnyxProps = { + workspaceRateAndUnit: OnyxEntry; +}; + +type WorkspaceRatePageProps = WorkspaceRatePageBaseProps & WorkspaceRateAndUnitOnyxProps; + +function WorkspaceRatePage(props: WorkspaceRatePageProps) { + const styles = useThemeStyles(); + const {translate, toLocaleDigit} = useLocalize(); + + useEffect(() => { + if (props.workspaceRateAndUnit?.policyID === props.policy?.id) { + return; + } + Policy.setPolicyIDForReimburseView(props.policy?.id ?? ''); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const submit = (values: OnyxFormValuesFields) => { + const rate = values.rate as string; + Policy.setRateForReimburseView((parseFloat(rate) * CONST.POLICY.CUSTOM_UNIT_RATE_BASE_OFFSET).toFixed(1)); + Navigation.navigate(ROUTES.WORKSPACE_RATE_AND_UNIT.getRoute(props.policy?.id ?? '')); + }; + + const validate = (values: OnyxFormValuesFields) => { + const errors: {rate?: string} = {}; + const rate = values.rate as string; + const parsedRate = MoneyRequestUtils.replaceAllDigits(rate, toLocaleDigit); + const decimalSeparator = toLocaleDigit('.'); + const outputCurrency = props.policy?.outputCurrency ?? CONST.CURRENCY.USD; + // Allow one more decimal place for accuracy + const rateValueRegex = RegExp(String.raw`^-?\d{0,8}([${getPermittedDecimalSeparator(decimalSeparator)}]\d{1,${CurrencyUtils.getCurrencyDecimals(outputCurrency) + 1}})?$`, 'i'); + if (!rateValueRegex.test(parsedRate) || parsedRate === '') { + errors.rate = 'workspace.reimburse.invalidRateError'; + } else if (NumberUtils.parseFloatAnyLocale(parsedRate) <= 0) { + errors.rate = 'workspace.reimburse.lowRateError'; + } + return errors; + }; + + const defaultValue = useMemo(() => { + const defaultDistanceCustomUnit = Object.values(props.policy?.customUnits ?? {}).find((unit) => unit.name === CONST.CUSTOM_UNITS.NAME_DISTANCE); + const distanceCustomRate = Object.values(defaultDistanceCustomUnit?.rates ?? {}).find((rate) => rate.name === CONST.CUSTOM_UNITS.DEFAULT_RATE); + return distanceCustomRate?.rate ?? 0; + }, [props.policy?.customUnits]); + + return ( + + {() => ( + + + + )} + + ); +} + +WorkspaceRatePage.displayName = 'WorkspaceRatePage'; + +export default compose( + withOnyx({ + workspaceRateAndUnit: { + key: ONYXKEYS.WORKSPACE_RATE_AND_UNIT, + }, + }), + withPolicy, +)(WorkspaceRatePage); diff --git a/src/pages/workspace/reimburse/WorkspaceRateAndUnitPage/UnitPage.tsx b/src/pages/workspace/reimburse/WorkspaceRateAndUnitPage/UnitPage.tsx new file mode 100644 index 000000000000..07f31d53193e --- /dev/null +++ b/src/pages/workspace/reimburse/WorkspaceRateAndUnitPage/UnitPage.tsx @@ -0,0 +1,111 @@ +import React, {useEffect, useMemo} from 'react'; +import {withOnyx} from 'react-native-onyx'; +import type {OnyxEntry} from 'react-native-onyx'; +import SelectionList from '@components/SelectionList'; +import Text from '@components/Text'; +import useLocalize from '@hooks/useLocalize'; +import useThemeStyles from '@hooks/useThemeStyles'; +import compose from '@libs/compose'; +import Navigation from '@libs/Navigation/Navigation'; +import withPolicy from '@pages/workspace/withPolicy'; +import type {WithPolicyProps} from '@pages/workspace/withPolicy'; +import WorkspacePageWithSections from '@pages/workspace/WorkspacePageWithSections'; +import * as Policy from '@userActions/Policy'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import ROUTES from '@src/ROUTES'; +import type {WorkspaceRateAndUnit} from '@src/types/onyx'; +import type {Unit} from '@src/types/onyx/Policy'; + +type OptionRow = { + value: Unit; + text: string; + keyForList: string; + isSelected: boolean; +}; + +type WorkspaceUnitPageBaseProps = WithPolicyProps; + +type WorkspaceRateAndUnitOnyxProps = { + workspaceRateAndUnit: OnyxEntry; +}; + +type WorkspaceUnitPageProps = WorkspaceUnitPageBaseProps & WorkspaceRateAndUnitOnyxProps; +function WorkspaceUnitPage(props: WorkspaceUnitPageProps) { + const styles = useThemeStyles(); + const {translate} = useLocalize(); + const unitItems = useMemo( + () => ({ + [CONST.CUSTOM_UNITS.DISTANCE_UNIT_KILOMETERS]: translate('workspace.reimburse.kilometers'), + [CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES]: translate('workspace.reimburse.miles'), + }), + [translate], + ); + + useEffect(() => { + if (props.workspaceRateAndUnit?.policyID === props.policy?.id) { + return; + } + Policy.setPolicyIDForReimburseView(props.policy?.id ?? ''); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const updateUnit = (unit: Unit) => { + Policy.setUnitForReimburseView(unit); + Navigation.navigate(ROUTES.WORKSPACE_RATE_AND_UNIT.getRoute(props.policy?.id ?? '')); + }; + + const defaultValue = useMemo(() => { + const defaultDistanceCustomUnit = Object.values(props.policy?.customUnits ?? {}).find((unit) => unit.name === CONST.CUSTOM_UNITS.NAME_DISTANCE); + return defaultDistanceCustomUnit?.attributes.unit ?? CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES; + }, [props.policy?.customUnits]); + + const unitOptions = useMemo(() => { + const arr: OptionRow[] = []; + Object.entries(unitItems).forEach(([unit, label]) => { + arr.push({ + value: unit as Unit, + text: label, + keyForList: unit, + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + isSelected: (props.workspaceRateAndUnit?.unit || defaultValue) === unit, + }); + }); + return arr; + }, [defaultValue, props.workspaceRateAndUnit?.unit, unitItems]); + + return ( + + {() => ( + <> + {translate('workspace.reimburse.trackDistanceChooseUnit')} + + updateUnit(unit.value)} + initiallyFocusedOptionKey={unitOptions.find((unit) => unit.isSelected)?.keyForList} + /> + + )} + + ); +} + +WorkspaceUnitPage.displayName = 'WorkspaceUnitPage'; + +export default compose( + withOnyx({ + workspaceRateAndUnit: { + key: ONYXKEYS.WORKSPACE_RATE_AND_UNIT, + }, + }), + withPolicy, +)(WorkspaceUnitPage); diff --git a/src/pages/workspace/reimburse/WorkspaceReimburseView.js b/src/pages/workspace/reimburse/WorkspaceReimburseView.js index 8749ea53bfc5..636675098d23 100644 --- a/src/pages/workspace/reimburse/WorkspaceReimburseView.js +++ b/src/pages/workspace/reimburse/WorkspaceReimburseView.js @@ -150,7 +150,10 @@ function WorkspaceReimburseView(props) { title={currentRatePerUnit} description={translate('workspace.reimburse.trackDistanceRate')} shouldShowRightIcon - onPress={() => Navigation.navigate(ROUTES.WORKSPACE_RATE_AND_UNIT.getRoute(props.policy.id))} + onPress={() => { + Policy.setPolicyIDForReimburseView(props.policy.id); + Navigation.navigate(ROUTES.WORKSPACE_RATE_AND_UNIT.getRoute(props.policy.id)); + }} wrapperStyle={[styles.sectionMenuItemTopDescription, styles.mt3]} brickRoadIndicator={(lodashGet(distanceCustomUnit, 'errors') || lodashGet(distanceCustomRate, 'errors')) && CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR} /> diff --git a/src/types/onyx/Policy.ts b/src/types/onyx/Policy.ts index d5ed5dd36aba..afdc236ca043 100644 --- a/src/types/onyx/Policy.ts +++ b/src/types/onyx/Policy.ts @@ -10,7 +10,7 @@ type Rate = { currency?: string; customUnitRateID?: string; errors?: OnyxCommon.Errors; - pendingAction?: string; + pendingAction?: OnyxCommon.PendingAction; }; type Attributes = { @@ -22,7 +22,7 @@ type CustomUnit = { customUnitID: string; attributes: Attributes; rates: Record; - pendingAction?: string; + pendingAction?: OnyxCommon.PendingAction; errors?: OnyxCommon.Errors; }; @@ -177,4 +177,4 @@ type Policy = { export default Policy; -export type {Unit, CustomUnit}; +export type {Unit, CustomUnit, Attributes, Rate}; diff --git a/src/types/onyx/WorkspaceRateAndUnit.ts b/src/types/onyx/WorkspaceRateAndUnit.ts new file mode 100644 index 000000000000..a374239c93f8 --- /dev/null +++ b/src/types/onyx/WorkspaceRateAndUnit.ts @@ -0,0 +1,14 @@ +type Unit = 'mi' | 'km'; + +type WorkspaceRateAndUnit = { + /** policyID of the Workspace */ + policyID: string; + + /** Unit of the Workspace */ + unit?: Unit; + + /** Unit of the Workspace */ + rate?: string; +}; + +export default WorkspaceRateAndUnit; diff --git a/src/types/onyx/index.ts b/src/types/onyx/index.ts index aae3b6f2532a..e7f4422c9fed 100644 --- a/src/types/onyx/index.ts +++ b/src/types/onyx/index.ts @@ -84,6 +84,7 @@ import type WalletOnfido from './WalletOnfido'; import type WalletStatement from './WalletStatement'; import type WalletTerms from './WalletTerms'; import type WalletTransfer from './WalletTransfer'; +import type WorkspaceRateAndUnit from './WorkspaceRateAndUnit'; export type { Account, @@ -163,6 +164,7 @@ export type { WalletStatement, WalletTerms, WalletTransfer, + WorkspaceRateAndUnit, WorkspaceSettingsForm, ReportUserIsTyping, PolicyReportField,