-
Notifications
You must be signed in to change notification settings - Fork 3k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Fix Workspace Rate Unit Form Flow #34849
Merged
tgolen
merged 43 commits into
Expensify:main
from
shubham1206agra:fix-workspace-rate-unit-flow
Feb 12, 2024
Merged
Changes from 9 commits
Commits
Show all changes
43 commits
Select commit
Hold shift + click to select a range
c7be0e6
WIP: test form flow
shubham1206agra aaa03ec
WIP: minor visual fixes
shubham1206agra 7ae7b0a
WIP: adding unit page, and amountForm component
shubham1206agra 126eedc
WIP: bug fixes
shubham1206agra eafe71d
WIP: bug fixes 2
shubham1206agra ef54837
WIP: rate page implementation
shubham1206agra bfa1572
Visual fixes
shubham1206agra e0060f6
prettier fix
shubham1206agra 52d7672
Merge branch 'Expensify:main' into fix-workspace-rate-unit-flow
shubham1206agra d9e62f8
merge main
shubham1206agra 0643d90
merge main
shubham1206agra 6cee6d5
ts fix
shubham1206agra cff1aee
added better translations
shubham1206agra 11c1529
corrected types
shubham1206agra c4cdb9b
using onyx instead of route query param to pass the values
shubham1206agra 6683966
Merge branch 'main' of https://github.com/Expensify/App into fix-work…
shubham1206agra eb92a06
merge main
shubham1206agra 332f379
minor fixes
shubham1206agra efea853
type fixes
shubham1206agra c1bbbc2
prettier fix
shubham1206agra c8cfca2
fixed error with changing value in AmountForm
shubham1206agra ecc6de6
fix translations
shubham1206agra bbf64b1
move onyx to actions
shubham1206agra 6c86011
convert initial page to ts
shubham1206agra d442ef5
improving comments
shubham1206agra b07ed29
fix type bug
shubham1206agra e091da5
allowed extra decimal for get accurate rate
shubham1206agra befbb42
fixed minor bug
shubham1206agra c7f62c9
fixed bug with round off
shubham1206agra 54da106
fixed bug with initial route
shubham1206agra d63311d
fix lint
shubham1206agra 327eead
Merge branch 'Expensify:main' into fix-workspace-rate-unit-flow
shubham1206agra 3797bdf
Merge branch 'main' into fix-workspace-rate-unit-flow
shubham1206agra 8103294
Merge branch 'Expensify:main' into fix-workspace-rate-unit-flow
shubham1206agra 840e931
fix ts
shubham1206agra 659fa23
fix naigation bug
shubham1206agra efd4b63
Merge branch 'main' into fix-workspace-rate-unit-flow
shubham1206agra f3e6326
Fix another navigation bug
shubham1206agra 5cde139
Merge branch 'main' into fix-workspace-rate-unit-flow
shubham1206agra 1d5a653
Fix style layout of rate page
shubham1206agra bae64bb
Fix header title
shubham1206agra 16445e5
Adding comments
shubham1206agra 6b91fe5
fix lint
shubham1206agra File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,251 @@ | ||
import type {ForwardedRef} from 'react'; | ||
import React, {forwardRef, useCallback, useEffect, 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?: number; | ||
|
||
/** Currency supplied by user */ | ||
currency?: string; | ||
|
||
/** Error to display at the bottom of the component */ | ||
errorText?: string; | ||
|
||
/** Callback to update the amount in the FormProvider */ | ||
onInputChange?: (value: number) => 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 isAmountInvalid = (amount: string) => !amount.length || parseFloat(amount) < 0.01; | ||
|
||
const AMOUNT_VIEW_ID = 'amountView'; | ||
const NUM_PAD_CONTAINER_VIEW_ID = 'numPadContainerView'; | ||
const NUM_PAD_VIEW_ID = 'numPadView'; | ||
|
||
function AmountForm({value: amount = 0, currency = CONST.CURRENCY.USD, errorText, onInputChange, onCurrencyButtonPress}: AmountFormProps, forwardedRef: ForwardedRef<TextInput>) { | ||
const styles = useThemeStyles(); | ||
const {toLocaleDigit, numberFormat} = useLocalize(); | ||
|
||
const textInput = useRef<TextInput | null>(null); | ||
|
||
const decimals = CurrencyUtils.getCurrencyDecimals(currency); | ||
const selectedAmountAsString = amount.toString(); | ||
|
||
const [currentAmount, setCurrentAmount] = useState(selectedAmountAsString); | ||
const [shouldUpdateSelection, setShouldUpdateSelection] = useState(true); | ||
|
||
const [selection, setSelection] = useState({ | ||
start: selectedAmountAsString.length, | ||
end: selectedAmountAsString.length, | ||
}); | ||
|
||
const forwardDeletePressedRef = useRef(false); | ||
|
||
/** | ||
* Event occurs when a user presses a mouse button over an DOM element. | ||
*/ | ||
const onMouseDown = (event: React.MouseEvent, ids: string[]) => { | ||
shubham1206agra marked this conversation as resolved.
Show resolved
Hide resolved
|
||
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 {String} 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; | ||
} | ||
|
||
// setCurrentAmount contains another setState(setSelection) making it error-prone since it is leading to | ||
// setSelection being called twice for a single setCurrentAmount call. This solution introducing the hasSelectionBeenSet | ||
// flag was chosen for its simplicity and lower risk of future errors https://github.com/Expensify/App/issues/23300#issuecomment-1766314724. | ||
|
||
let hasSelectionBeenSet = false; | ||
setCurrentAmount((prevAmount) => { | ||
const strippedAmount = MoneyRequestUtils.stripCommaFromAmount(newAmountWithoutSpaces); | ||
const isForwardDelete = prevAmount.length > strippedAmount.length && forwardDeletePressedRef.current; | ||
if (!hasSelectionBeenSet) { | ||
hasSelectionBeenSet = true; | ||
setSelection((prevSelection) => getNewSelection(prevSelection, isForwardDelete ? strippedAmount.length : prevAmount.length, strippedAmount.length)); | ||
} | ||
onInputChange?.(parseFloat(strippedAmount)); | ||
return strippedAmount; | ||
}); | ||
}, | ||
[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 | ||
}, [setNewAmount]); | ||
|
||
/** | ||
* 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 | ||
* | ||
* @param {String} key | ||
*/ | ||
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 {Boolean} 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<KeyboardEvent>) => { | ||
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 ( | ||
<> | ||
<View | ||
id={AMOUNT_VIEW_ID} | ||
onMouseDown={(event) => onMouseDown(event, [AMOUNT_VIEW_ID])} | ||
style={[styles.moneyRequestAmountContainer, styles.flex1, styles.flexRow, styles.w100, styles.alignItemsCenter, styles.justifyContentCenter]} | ||
> | ||
<TextInputWithCurrencySymbol | ||
// @ts-expect-error: TODO: fix this | ||
formattedAmount={formattedAmount} | ||
onChangeAmount={setNewAmount} | ||
onCurrencyButtonPress={onCurrencyButtonPress} | ||
placeholder={numberFormat(0)} | ||
ref={(ref: TextInput) => { | ||
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<TextInputSelectionChangeEventData>) => { | ||
if (!shouldUpdateSelection) { | ||
return; | ||
} | ||
setSelection(e.nativeEvent.selection); | ||
}} | ||
onKeyPress={textInputKeyPress} | ||
/> | ||
{!!errorText && ( | ||
<FormHelpMessage | ||
style={[styles.pAbsolute, styles.b0, styles.mb0, styles.w100]} | ||
isError | ||
message={errorText} | ||
/> | ||
)} | ||
</View> | ||
{canUseTouchScreen ? ( | ||
<View | ||
onMouseDown={(event) => onMouseDown(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} | ||
> | ||
<BigNumberPad | ||
id={NUM_PAD_VIEW_ID} | ||
numberPressed={updateAmountNumberPad} | ||
longPressHandlerStateChanged={updateLongPressHandlerState} | ||
/> | ||
</View> | ||
) : null} | ||
</> | ||
); | ||
} | ||
|
||
AmountForm.displayName = 'AmountForm'; | ||
|
||
export default forwardRef(AmountForm); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,32 @@ | ||
import type {ForwardedRef} from 'react'; | ||
import React, {forwardRef} from 'react'; | ||
import type {View} from 'react-native'; | ||
import type {AvatarProps, IconProps, MenuItemBaseProps, NoIcon} from './MenuItem'; | ||
import MenuItem from './MenuItem'; | ||
|
||
type FormMenuItemProps = (NoIcon | AvatarProps | IconProps) & | ||
Omit<MenuItemBaseProps, 'description' | 'error' | 'brickRoadIndicator'> & { | ||
/** A description text to show under the title provided by the FormProvider */ | ||
value?: string; | ||
|
||
/** Custom value renderer to render description based on form values */ | ||
customValueRenderer?: (value?: string) => string | undefined; | ||
}; | ||
|
||
function FormMenuItem({customValueRenderer, value, errorText, ...props}: FormMenuItemProps, ref: ForwardedRef<View>) { | ||
return ( | ||
<MenuItem | ||
// eslint-disable-next-line react/jsx-props-no-spreading | ||
{...props} | ||
ref={ref} | ||
description={customValueRenderer ? customValueRenderer(value) : value} | ||
errorText={errorText} | ||
brickRoadIndicator={errorText && errorText !== '' ? 'error' : undefined} | ||
/> | ||
); | ||
} | ||
|
||
FormMenuItem.displayName = 'FormMenuItem'; | ||
|
||
export type {FormMenuItemProps}; | ||
export default forwardRef(FormMenuItem); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This entire block is confusing. Is there a way to simplify the parameters based on what the route actually ends with? This way, there is no need to conditionally check if it's rate or unit that is set.
For instance, for the
WORKSPACE_RATE_AND_UNIT_RATE
route, just returnworkspace/${policyID}/rateandunit/rate
, and then load the rate value from Onyx.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Am I allowed to create a new Onyx key for this?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I reckon that's fine since we're moving away from a form to individual push-to-page screens. @tgolen Any thoughts on this?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Adding new Onyx keys usually needs to be coordinated with the backend to ensure the new key is being populated properly. I might be missing some context for the rest of the question.
What Onyx key are you wanting to add and what does that have to do with the suggestion here (which I agree with) to clean up the routes?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
No it will be a temporary key. No need to sync it with backend.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Please answer my question:
What Onyx key are you wanting to add and what does that have to do with the suggestion here?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I haven't thought of a name yet, but the Onyx key will behave similar to Task key, whose function would be to hold the values temporarily while we are editing the RateUnit form.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@akinwale Can you give another pass at the PR?