Skip to content
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
merged 43 commits into from
Feb 12, 2024
Merged
Show file tree
Hide file tree
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 Jan 20, 2024
aaa03ec
WIP: minor visual fixes
shubham1206agra Jan 20, 2024
7ae7b0a
WIP: adding unit page, and amountForm component
shubham1206agra Jan 21, 2024
126eedc
WIP: bug fixes
shubham1206agra Jan 21, 2024
eafe71d
WIP: bug fixes 2
shubham1206agra Jan 21, 2024
ef54837
WIP: rate page implementation
shubham1206agra Jan 21, 2024
bfa1572
Visual fixes
shubham1206agra Jan 21, 2024
e0060f6
prettier fix
shubham1206agra Jan 21, 2024
52d7672
Merge branch 'Expensify:main' into fix-workspace-rate-unit-flow
shubham1206agra Jan 22, 2024
d9e62f8
merge main
shubham1206agra Jan 31, 2024
0643d90
merge main
shubham1206agra Jan 31, 2024
6cee6d5
ts fix
shubham1206agra Jan 31, 2024
cff1aee
added better translations
shubham1206agra Feb 1, 2024
11c1529
corrected types
shubham1206agra Feb 1, 2024
c4cdb9b
using onyx instead of route query param to pass the values
shubham1206agra Feb 2, 2024
6683966
Merge branch 'main' of https://github.com/Expensify/App into fix-work…
shubham1206agra Feb 2, 2024
eb92a06
merge main
shubham1206agra Feb 2, 2024
332f379
minor fixes
shubham1206agra Feb 2, 2024
efea853
type fixes
shubham1206agra Feb 2, 2024
c1bbbc2
prettier fix
shubham1206agra Feb 2, 2024
c8cfca2
fixed error with changing value in AmountForm
shubham1206agra Feb 3, 2024
ecc6de6
fix translations
shubham1206agra Feb 4, 2024
bbf64b1
move onyx to actions
shubham1206agra Feb 4, 2024
6c86011
convert initial page to ts
shubham1206agra Feb 4, 2024
d442ef5
improving comments
shubham1206agra Feb 4, 2024
b07ed29
fix type bug
shubham1206agra Feb 4, 2024
e091da5
allowed extra decimal for get accurate rate
shubham1206agra Feb 4, 2024
befbb42
fixed minor bug
shubham1206agra Feb 5, 2024
c7f62c9
fixed bug with round off
shubham1206agra Feb 5, 2024
54da106
fixed bug with initial route
shubham1206agra Feb 5, 2024
d63311d
fix lint
shubham1206agra Feb 5, 2024
327eead
Merge branch 'Expensify:main' into fix-workspace-rate-unit-flow
shubham1206agra Feb 5, 2024
3797bdf
Merge branch 'main' into fix-workspace-rate-unit-flow
shubham1206agra Feb 6, 2024
8103294
Merge branch 'Expensify:main' into fix-workspace-rate-unit-flow
shubham1206agra Feb 8, 2024
840e931
fix ts
shubham1206agra Feb 8, 2024
659fa23
fix naigation bug
shubham1206agra Feb 8, 2024
efd4b63
Merge branch 'main' into fix-workspace-rate-unit-flow
shubham1206agra Feb 8, 2024
f3e6326
Fix another navigation bug
shubham1206agra Feb 8, 2024
5cde139
Merge branch 'main' into fix-workspace-rate-unit-flow
shubham1206agra Feb 11, 2024
1d5a653
Fix style layout of rate page
shubham1206agra Feb 11, 2024
bae64bb
Fix header title
shubham1206agra Feb 12, 2024
16445e5
Adding comments
shubham1206agra Feb 12, 2024
6b91fe5
fix lint
shubham1206agra Feb 12, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 15 additions & 1 deletion src/ROUTES.ts
Original file line number Diff line number Diff line change
Expand Up @@ -448,7 +448,21 @@ const ROUTES = {
},
WORKSPACE_RATE_AND_UNIT: {
route: 'workspace/:policyID/rateandunit',
getRoute: (policyID: string) => `workspace/${policyID}/rateandunit` as const,
getRoute: (policyID: string, unit?: string, rate?: string) =>
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
`workspace/${policyID}/rateandunit${unit || rate ? '?' : ''}${unit ? `unit=${unit}` : ''}${unit && rate ? '&' : ''}${rate ? `rate=${rate}` : ''}` as const,
},
WORKSPACE_RATE_AND_UNIT_RATE: {
route: 'workspace/:policyID/rateandunit/rate',
getRoute: (policyID: string, unit?: string, rate?: string) =>
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
`workspace/${policyID}/rateandunit/rate${unit || rate ? '?' : ''}${unit ? `unit=${unit}` : ''}${unit && rate ? '&' : ''}${rate ? `rate=${rate}` : ''}` as const,
},
WORKSPACE_RATE_AND_UNIT_UNIT: {
route: 'workspace/:policyID/rateandunit/unit',
getRoute: (policyID: string, unit?: string, rate?: string) =>
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
`workspace/${policyID}/rateandunit/unit${unit || rate ? '?' : ''}${unit ? `unit=${unit}` : ''}${unit && rate ? '&' : ''}${rate ? `rate=${rate}` : ''}` as const,
Copy link
Contributor

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 return workspace/${policyID}/rateandunit/rate, and then load the rate value from Onyx.

Copy link
Contributor Author

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?

Copy link
Contributor

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?

Copy link
Contributor

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?

Copy link
Contributor Author

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.

Copy link
Contributor

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?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What Onyx key are you wanting to add and what does that have to do with the suggestion here?

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.

Copy link
Contributor Author

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?

},
WORKSPACE_BILLS: {
route: 'workspace/:policyID/bills',
Expand Down
6 changes: 5 additions & 1 deletion src/SCREENS.ts
Original file line number Diff line number Diff line change
Expand Up @@ -195,7 +195,11 @@ const SCREENS = {
SETTINGS: 'Workspace_Settings',
CARD: 'Workspace_Card',
REIMBURSE: 'Workspace_Reimburse',
RATE_AND_UNIT: 'Workspace_RateAndUnit',
RATE_AND_UNIT: {
ROOT: 'Workspace_RateAndUnit_Root',
RATE: 'Workspace_RateAndUnit_Rate',
UNIT: 'Workspace_RateAndUnit_Unit',
},
BILLS: 'Workspace_Bills',
INVOICES: 'Workspace_Invoices',
TRAVEL: 'Workspace_Travel',
Expand Down
251 changes: 251 additions & 0 deletions src/components/AmountForm.tsx
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[]) => {
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);
2 changes: 1 addition & 1 deletion src/components/BigNumberPad.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ type BigNumberPadProps = {
id?: string;

/** Whether long press is disabled */
isLongPressDisabled: boolean;
isLongPressDisabled?: boolean;
};

const padNumbers = [
Expand Down
8 changes: 7 additions & 1 deletion src/components/Form/FormWrapper.js
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,9 @@ const propTypes = {
/** Submit button styles */
submitButtonStyles: stylePropTypes,

/** Whether to apply flex to the submit button */
submitFlexEnabled: PropTypes.bool,

/** Custom content to display in the footer after submit button */
footerContent: PropTypes.oneOfType([PropTypes.func, PropTypes.node]),

Expand All @@ -81,6 +84,7 @@ const defaultProps = {
footerContent: null,
style: [],
submitButtonStyles: [],
submitFlexEnabled: true,
shouldHideFixErrorsAlert: false,
};

Expand All @@ -97,6 +101,7 @@ function FormWrapper(props) {
isSubmitButtonVisible,
style,
submitButtonStyles,
submitFlexEnabled,
enabledWhenOffline,
isSubmitActionDangerous,
formID,
Expand Down Expand Up @@ -153,7 +158,7 @@ function FormWrapper(props) {
focusInput.focus();
}
}}
containerStyles={[styles.mh0, styles.mt5, styles.flex1, ...submitButtonStyles]}
containerStyles={[styles.mh0, styles.mt5, submitFlexEnabled ? styles.flex1 : {}, ...submitButtonStyles]}
enabledWhenOffline={enabledWhenOffline}
isSubmitActionDangerous={isSubmitActionDangerous}
disablePressOnEnter
Expand All @@ -180,6 +185,7 @@ function FormWrapper(props) {
styles.mh0,
styles.mt5,
submitButtonStyles,
submitFlexEnabled,
submitButtonText,
shouldHideFixErrorsAlert,
],
Expand Down
32 changes: 32 additions & 0 deletions src/components/FormMenuItem.tsx
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);
6 changes: 4 additions & 2 deletions src/components/MenuItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ type NoIcon = {
icon?: undefined;
};

type MenuItemProps = (IconProps | AvatarProps | NoIcon) & {
type MenuItemBaseProps = {
/** Function to fire when component is pressed */
onPress?: (event: GestureResponderEvent | KeyboardEvent) => void;

Expand Down Expand Up @@ -225,6 +225,8 @@ type MenuItemProps = (IconProps | AvatarProps | NoIcon) & {
contentFit?: ImageContentFit;
};

type MenuItemProps = (IconProps | AvatarProps | NoIcon) & MenuItemBaseProps;

function MenuItem(
{
interactive = true,
Expand Down Expand Up @@ -604,5 +606,5 @@ function MenuItem(

MenuItem.displayName = 'MenuItem';

export type {MenuItemProps};
export type {IconProps, AvatarProps, NoIcon, MenuItemBaseProps, MenuItemProps};
export default forwardRef(MenuItem);
Loading
Loading