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 all 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
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 @@ -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,
Expand Down
2 changes: 2 additions & 0 deletions src/SCREENS.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
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;
Copy link
Contributor

Choose a reason for hiding this comment

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

Why isn't this part of submitButtonStyles?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Tried this, but wasn't working correctly, so I decided to remove flex via prop.

Copy link
Contributor

Choose a reason for hiding this comment

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

Can you expand on that a little more? What wasn't working?

Could you make flex1 part of the default styles, and then in the one component where it currently has submitFlexEnabled=false, pass styles.flexReset?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

flexReset with flex1 doesn't work for some reason. It applies flex1 only.

};

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
Loading