Skip to content

Commit

Permalink
Merge pull request #34849 from shubham1206agra/fix-workspace-rate-uni…
Browse files Browse the repository at this point in the history
…t-flow

Fix Workspace Rate Unit Form Flow
  • Loading branch information
tgolen authored Feb 12, 2024
2 parents b3c664b + 6b91fe5 commit 698b4e2
Show file tree
Hide file tree
Showing 28 changed files with 786 additions and 195 deletions.
9 changes: 9 additions & 0 deletions src/ONYXKEYS.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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<string, OnyxTypes.Currency>;
[ONYXKEYS.UPDATE_AVAILABLE]: boolean;
[ONYXKEYS.SCREEN_SHARE_REQUEST]: OnyxTypes.ScreenShareRequest;
Expand Down
8 changes: 8 additions & 0 deletions src/ROUTES.ts
Original file line number Diff line number Diff line change
Expand Up @@ -464,6 +464,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,
Expand Down
2 changes: 2 additions & 0 deletions src/SCREENS.ts
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,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',
Expand Down
241 changes: 241 additions & 0 deletions src/components/AmountForm.tsx
Original file line number Diff line number Diff line change
@@ -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<TextInput>,
) {
const styles = useThemeStyles();
const {toLocaleDigit, numberFormat} = useLocalize();

const textInput = useRef<TextInput | null>(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<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) => focusTextInput(event, [AMOUNT_VIEW_ID])}
style={[styles.moneyRequestAmountContainer, styles.flex1, styles.flexRow, styles.w100, styles.alignItemsCenter, styles.justifyContentCenter]}
>
<TextInputWithCurrencySymbol
// @ts-expect-error: Migration pending
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) => 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}
>
<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/FormProvider.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -62,6 +62,12 @@ type FormProviderProps<TFormID extends OnyxFormKey = OnyxFormKey> = 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<ViewStyle>;

/** Whether to apply flex to the submit button */
submitFlexEnabled?: boolean;
};

type FormRef<TFormID extends OnyxFormKey = OnyxFormKey> = {
Expand Down
7 changes: 6 additions & 1 deletion src/components/Form/FormWrapper.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,9 @@ type FormWrapperProps = ChildrenProps &
/** Submit button styles */
submitButtonStyles?: StyleProp<ViewStyle>;

/** Whether to apply flex to the submit button */
submitFlexEnabled?: boolean;

/** Server side errors keyed by microtime */
errors: Errors;

Expand All @@ -49,6 +52,7 @@ function FormWrapper({
isSubmitButtonVisible = true,
style,
submitButtonStyles,
submitFlexEnabled = true,
enabledWhenOffline,
isSubmitActionDangerous = false,
formID,
Expand Down Expand Up @@ -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}
Expand All @@ -134,6 +138,7 @@ function FormWrapper({
styles.mh0,
styles.mt5,
submitButtonStyles,
submitFlexEnabled,
submitButtonText,
shouldHideFixErrorsAlert,
onFixTheErrorsLinkPressed,
Expand Down
3 changes: 2 additions & 1 deletion src/components/Form/types.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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';

Expand Down
6 changes: 4 additions & 2 deletions src/components/MenuItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>;

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

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

function MenuItem(
{
interactive = true,
Expand Down Expand Up @@ -625,5 +627,5 @@ function MenuItem(

MenuItem.displayName = 'MenuItem';

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

0 comments on commit 698b4e2

Please sign in to comment.