diff --git a/android/app/build.gradle b/android/app/build.gradle index cbfdb4b0b0a5..ed7b2f568b93 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -98,8 +98,8 @@ android { minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion multiDexEnabled rootProject.ext.multiDexEnabled - versionCode 1001043902 - versionName "1.4.39-2" + versionCode 1001043907 + versionName "1.4.39-7" } flavorDimensions "default" diff --git a/assets/images/chatbubble-add.svg b/assets/images/chatbubble-add.svg index 047a43073b3c..48eebf863cc3 100644 --- a/assets/images/chatbubble-add.svg +++ b/assets/images/chatbubble-add.svg @@ -1,13 +1 @@ - - - - - - + \ No newline at end of file diff --git a/assets/images/chatbubble-unread.svg b/assets/images/chatbubble-unread.svg index 9da789510276..492616cf2ab5 100644 --- a/assets/images/chatbubble-unread.svg +++ b/assets/images/chatbubble-unread.svg @@ -1,12 +1 @@ - - - - - - + \ No newline at end of file diff --git a/assets/images/home.svg b/assets/images/home.svg index 6b2411407be7..d4e02b723fee 100644 --- a/assets/images/home.svg +++ b/assets/images/home.svg @@ -1,3 +1 @@ - - - + \ No newline at end of file diff --git a/assets/images/olddot-wireframe.svg b/assets/images/olddot-wireframe.svg index ee9aa93be255..055059edfd70 100644 --- a/assets/images/olddot-wireframe.svg +++ b/assets/images/olddot-wireframe.svg @@ -1,3422 +1 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + \ No newline at end of file diff --git a/assets/images/simple-illustrations/simple-illustration__gears.svg b/assets/images/simple-illustrations/simple-illustration__gears.svg index 3b4cbc001e3b..2798feb4e04d 100644 --- a/assets/images/simple-illustrations/simple-illustration__gears.svg +++ b/assets/images/simple-illustrations/simple-illustration__gears.svg @@ -1,101 +1 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + \ No newline at end of file diff --git a/assets/images/simple-illustrations/simple-illustration__lockclosed.svg b/assets/images/simple-illustrations/simple-illustration__lockclosed.svg index 3779b92b0b0f..791500c28032 100644 --- a/assets/images/simple-illustrations/simple-illustration__lockclosed.svg +++ b/assets/images/simple-illustrations/simple-illustration__lockclosed.svg @@ -1,17 +1 @@ - - - - - - - - - - - - - - - - - + \ No newline at end of file diff --git a/assets/images/simple-illustrations/simple-illustration__palmtree.svg b/assets/images/simple-illustrations/simple-illustration__palmtree.svg index 2aef4956cde9..c67e871dc434 100644 --- a/assets/images/simple-illustrations/simple-illustration__palmtree.svg +++ b/assets/images/simple-illustrations/simple-illustration__palmtree.svg @@ -1,15 +1 @@ - - - - - - - - - - - - - - - + \ No newline at end of file diff --git a/assets/images/simple-illustrations/simple-illustration__profile.svg b/assets/images/simple-illustrations/simple-illustration__profile.svg index 85312f26e186..085f02822bc0 100644 --- a/assets/images/simple-illustrations/simple-illustration__profile.svg +++ b/assets/images/simple-illustrations/simple-illustration__profile.svg @@ -1,6 +1 @@ - - - - - - + \ No newline at end of file diff --git a/assets/images/simple-illustrations/simple-illustration__qr-code.svg b/assets/images/simple-illustrations/simple-illustration__qr-code.svg index 10268d747588..7bd460d5f4e9 100644 --- a/assets/images/simple-illustrations/simple-illustration__qr-code.svg +++ b/assets/images/simple-illustrations/simple-illustration__qr-code.svg @@ -1,4 +1 @@ - - - - + \ No newline at end of file diff --git a/desktop/main.js b/desktop/main.js index e53f03530b57..4b38c5d36ab3 100644 --- a/desktop/main.js +++ b/desktop/main.js @@ -410,6 +410,14 @@ const mainWindow = () => { browserWindow.webContents.goBack(); }, }, + { + role: 'back', + visible: false, + accelerator: process.platform === 'darwin' ? 'Cmd+Left' : 'Shift+Left', + click: () => { + browserWindow.webContents.goBack(); + }, + }, { id: 'forward', role: 'forward', @@ -418,6 +426,14 @@ const mainWindow = () => { browserWindow.webContents.goForward(); }, }, + { + role: 'forward', + visible: false, + accelerator: process.platform === 'darwin' ? 'Cmd+Right' : 'Shift+Right', + click: () => { + browserWindow.webContents.goForward(); + }, + }, ], }, { diff --git a/docs/assets/images/info.svg b/docs/assets/images/info.svg index 96924fbb6cf7..fbe9b3612667 100644 --- a/docs/assets/images/info.svg +++ b/docs/assets/images/info.svg @@ -1,9 +1 @@ - - - - - - - - - + \ No newline at end of file diff --git a/ios/NewExpensify/Info.plist b/ios/NewExpensify/Info.plist index 0c53a62c88a6..4f571739ecc7 100644 --- a/ios/NewExpensify/Info.plist +++ b/ios/NewExpensify/Info.plist @@ -40,7 +40,7 @@ CFBundleVersion - 1.4.39.2 + 1.4.39.7 ITSAppUsesNonExemptEncryption LSApplicationQueriesSchemes diff --git a/ios/NewExpensifyTests/Info.plist b/ios/NewExpensifyTests/Info.plist index ed74e6865d2f..bc29fdff60c2 100644 --- a/ios/NewExpensifyTests/Info.plist +++ b/ios/NewExpensifyTests/Info.plist @@ -19,6 +19,6 @@ CFBundleSignature ???? CFBundleVersion - 1.4.39.2 + 1.4.39.7 diff --git a/ios/NotificationServiceExtension/Info.plist b/ios/NotificationServiceExtension/Info.plist index 94c770eaf29f..b54ddc36ddf0 100644 --- a/ios/NotificationServiceExtension/Info.plist +++ b/ios/NotificationServiceExtension/Info.plist @@ -13,7 +13,7 @@ CFBundleShortVersionString 1.4.39 CFBundleVersion - 1.4.39.2 + 1.4.39.7 NSExtension NSExtensionPointIdentifier diff --git a/package-lock.json b/package-lock.json index 694646334480..1cc630ce5dac 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "new.expensify", - "version": "1.4.39-2", + "version": "1.4.39-7", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "new.expensify", - "version": "1.4.39-2", + "version": "1.4.39-7", "hasInstallScript": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index 0f856afb44e0..5870241897b9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "new.expensify", - "version": "1.4.39-2", + "version": "1.4.39-7", "author": "Expensify, Inc.", "homepage": "https://new.expensify.com", "description": "New Expensify is the next generation of Expensify: a reimagination of payments based atop a foundation of chat.", diff --git a/patches/react-native-vision-camera+2.16.5.patch b/patches/react-native-vision-camera+2.16.5.patch deleted file mode 100644 index d08f7c11f5f3..000000000000 --- a/patches/react-native-vision-camera+2.16.5.patch +++ /dev/null @@ -1,13 +0,0 @@ -diff --git a/node_modules/react-native-vision-camera/android/src/main/java/com/mrousavy/camera/frameprocessor/FrameProcessorRuntimeManager.kt b/node_modules/react-native-vision-camera/android/src/main/java/com/mrousavy/camera/frameprocessor/FrameProcessorRuntimeManager.kt -index c0a8b23..653b51e 100644 ---- a/node_modules/react-native-vision-camera/android/src/main/java/com/mrousavy/camera/frameprocessor/FrameProcessorRuntimeManager.kt -+++ b/node_modules/react-native-vision-camera/android/src/main/java/com/mrousavy/camera/frameprocessor/FrameProcessorRuntimeManager.kt -@@ -40,7 +40,7 @@ class FrameProcessorRuntimeManager(context: ReactApplicationContext, frameProces - val holder = context.catalystInstance.jsCallInvokerHolder as CallInvokerHolderImpl - mScheduler = VisionCameraScheduler(frameProcessorThread) - mContext = WeakReference(context) -- mHybridData = initHybrid(context.javaScriptContextHolder.get(), holder, mScheduler!!) -+ mHybridData = initHybrid(context.javaScriptContextHolder!!.get(), holder, mScheduler!!) - initializeRuntime() - - Log.i(TAG, "Installing JSI Bindings on JS Thread...") diff --git a/patches/react-native-vision-camera+2.16.5+001+fix-boost-dependency.patch b/patches/react-native-vision-camera+2.16.8.patch similarity index 100% rename from patches/react-native-vision-camera+2.16.5+001+fix-boost-dependency.patch rename to patches/react-native-vision-camera+2.16.8.patch diff --git a/src/CONST.ts b/src/CONST.ts index 79895d20aa57..eae4b8ec7a2b 100755 --- a/src/CONST.ts +++ b/src/CONST.ts @@ -1506,7 +1506,7 @@ const CONST = { GUIDES_CALL_TASK_IDS: { CONCIERGE_DM: 'NewExpensifyConciergeDM', WORKSPACE_INITIAL: 'WorkspaceHome', - WORKSPACE_OVERVIEW: 'WorkspaceOverview', + WORKSPACE_PROFILE: 'WorkspaceProfile', WORKSPACE_CARD: 'WorkspaceCorporateCards', WORKSPACE_REIMBURSE: 'WorkspaceReimburseReceipts', WORKSPACE_BILLS: 'WorkspacePayBills', @@ -1567,6 +1567,10 @@ const CONST = { FORM_CHARACTER_LIMIT: 50, LEGAL_NAMES_CHARACTER_LIMIT: 150, LOGIN_CHARACTER_LIMIT: 254, + + TITLE_CHARACTER_LIMIT: 100, + DESCRIPTION_LIMIT: 500, + WORKSPACE_NAME_CHARACTER_LIMIT: 80, AVATAR_CROP_MODAL: { // The next two constants control what is min and max value of the image crop scale. diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts index cbf4d7714967..b46d3db8b60d 100755 --- a/src/ONYXKEYS.ts +++ b/src/ONYXKEYS.ts @@ -465,7 +465,8 @@ type OnyxValues = { [ONYXKEYS.COLLECTION.POLICY_RECENTLY_USED_CATEGORIES]: OnyxTypes.RecentlyUsedCategories; [ONYXKEYS.COLLECTION.POLICY_REPORT_FIELDS]: OnyxTypes.PolicyReportFields; [ONYXKEYS.COLLECTION.DEPRECATED_POLICY_MEMBER_LIST]: OnyxTypes.PolicyMembers; - [ONYXKEYS.COLLECTION.WORKSPACE_INVITE_MEMBERS_DRAFT]: Record; + [ONYXKEYS.COLLECTION.WORKSPACE_INVITE_MEMBERS_DRAFT]: OnyxTypes.InvitedEmailsToAccountIDs | undefined; + [ONYXKEYS.COLLECTION.WORKSPACE_INVITE_MESSAGE_DRAFT]: string | undefined; [ONYXKEYS.COLLECTION.REPORT]: OnyxTypes.Report; [ONYXKEYS.COLLECTION.REPORT_METADATA]: OnyxTypes.ReportMetadata; [ONYXKEYS.COLLECTION.REPORT_ACTIONS]: OnyxTypes.ReportActions; diff --git a/src/ROUTES.ts b/src/ROUTES.ts index 678dbe433417..4424c07765c6 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -436,17 +436,17 @@ const ROUTES = { route: 'workspace/:policyID/invite-message', getRoute: (policyID: string) => `workspace/${policyID}/invite-message` as const, }, - WORKSPACE_OVERVIEW: { - route: 'workspace/:policyID/overview', - getRoute: (policyID: string) => `workspace/${policyID}/overview` as const, + WORKSPACE_PROFILE: { + route: 'workspace/:policyID/profile', + getRoute: (policyID: string) => `workspace/${policyID}/profile` as const, }, - WORKSPACE_OVERVIEW_CURRENCY: { - route: 'workspace/:policyID/overview/currency', - getRoute: (policyID: string) => `workspace/${policyID}/overview/currency` as const, + WORKSPACE_PROFILE_CURRENCY: { + route: 'workspace/:policyID/profile/currency', + getRoute: (policyID: string) => `workspace/${policyID}/profile/currency` as const, }, - WORKSPACE_OVERVIEW_NAME: { - route: 'workspace/:policyID/overview/name', - getRoute: (policyID: string) => `workspace/${policyID}/overview/name` as const, + WORKSPACE_PROFILE_NAME: { + route: 'workspace/:policyID/profile/name', + getRoute: (policyID: string) => `workspace/${policyID}/profile/name` as const, }, WORKSPACE_AVATAR: { route: 'workspace/:policyID/avatar', diff --git a/src/SCREENS.ts b/src/SCREENS.ts index cd80937a3864..1d7c77bf129c 100644 --- a/src/SCREENS.ts +++ b/src/SCREENS.ts @@ -194,7 +194,7 @@ const SCREENS = { WORKSPACE: { INITIAL: 'Workspace_Initial', - OVERVIEW: 'Workspace_Overview', + PROFILE: 'Workspace_Profile', CARD: 'Workspace_Card', REIMBURSE: 'Workspace_Reimburse', RATE_AND_UNIT: 'Workspace_RateAndUnit', @@ -204,8 +204,8 @@ const SCREENS = { MEMBERS: 'Workspace_Members', INVITE: 'Workspace_Invite', INVITE_MESSAGE: 'Workspace_Invite_Message', - CURRENCY: 'Workspace_Overview_Currency', - NAME: 'Workspace_Overview_Name', + CURRENCY: 'Workspace_Profile_Currency', + NAME: 'Workspace_Profile_Name', }, EDIT_REQUEST: { diff --git a/src/components/AmountTextInput.tsx b/src/components/AmountTextInput.tsx index 245aa2126d08..05080fcdd21c 100644 --- a/src/components/AmountTextInput.tsx +++ b/src/components/AmountTextInput.tsx @@ -43,7 +43,6 @@ function AmountTextInput( disableKeyboard autoGrow hideFocusedState - shouldInterceptSwipe inputStyle={[styles.iouAmountTextInput, styles.p0, styles.noLeftBorderRadius, styles.noRightBorderRadius, style]} textInputContainerStyles={[styles.borderNone, styles.noLeftBorderRadius, styles.noRightBorderRadius]} onChangeText={onChangeAmount} diff --git a/src/components/ContextMenuItem.tsx b/src/components/ContextMenuItem.tsx index d292f80d135e..d6c8fd973983 100644 --- a/src/components/ContextMenuItem.tsx +++ b/src/components/ContextMenuItem.tsx @@ -1,6 +1,6 @@ import type {ForwardedRef} from 'react'; import React, {forwardRef, useImperativeHandle} from 'react'; -import type {GestureResponderEvent} from 'react-native'; +import type {GestureResponderEvent, StyleProp, ViewStyle} from 'react-native'; import useStyleUtils from '@hooks/useStyleUtils'; import useThemeStyles from '@hooks/useThemeStyles'; import useThrottledButtonState from '@hooks/useThrottledButtonState'; @@ -41,6 +41,9 @@ type ContextMenuItemProps = { /** Whether the width should be limited */ shouldLimitWidth?: boolean; + + /** Styles to apply to ManuItem wrapper */ + wrapperStyle?: StyleProp; }; type ContextMenuItemHandle = { @@ -48,7 +51,19 @@ type ContextMenuItemHandle = { }; function ContextMenuItem( - {onPress, successIcon, successText = '', icon, text, isMini = false, description = '', isAnonymousAction = false, isFocused = false, shouldLimitWidth = true}: ContextMenuItemProps, + { + onPress, + successIcon, + successText = '', + icon, + text, + isMini = false, + description = '', + isAnonymousAction = false, + isFocused = false, + shouldLimitWidth = true, + wrapperStyle, + }: ContextMenuItemProps, ref: ForwardedRef, ) { const styles = useThemeStyles(); @@ -93,7 +108,7 @@ function ContextMenuItem( title={itemText} icon={itemIcon} onPress={triggerPressAndUpdateSuccess} - wrapperStyle={styles.pr9} + wrapperStyle={[styles.pr9, wrapperStyle]} success={!isThrottledButtonActive} description={description} descriptionTextStyle={styles.breakWord} diff --git a/src/components/IFrame.tsx b/src/components/IFrame.tsx index ab27597aeebd..05da3a1edb9c 100644 --- a/src/components/IFrame.tsx +++ b/src/components/IFrame.tsx @@ -37,7 +37,7 @@ function getNewDotURL(url: string): string { if (pathname === 'policy') { const workspaceID = params.policyID || ''; - const section = urlObj.hash.slice(1) || 'overview'; + const section = urlObj.hash.slice(1) || 'profile'; return `workspace/${workspaceID}/${section}`; } diff --git a/src/components/ReportActionItem/MoneyReportView.tsx b/src/components/ReportActionItem/MoneyReportView.tsx index 133996fde41c..f0cd8dc1b4b5 100644 --- a/src/components/ReportActionItem/MoneyReportView.tsx +++ b/src/components/ReportActionItem/MoneyReportView.tsx @@ -9,7 +9,6 @@ import OfflineWithFeedback from '@components/OfflineWithFeedback'; import SpacerView from '@components/SpacerView'; import Text from '@components/Text'; import useLocalize from '@hooks/useLocalize'; -import usePermissions from '@hooks/usePermissions'; import useStyleUtils from '@hooks/useStyleUtils'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -42,7 +41,6 @@ function MoneyReportView({report, policy, policyReportFields, shouldShowHorizont const StyleUtils = useStyleUtils(); const {translate} = useLocalize(); const {isSmallScreenWidth} = useWindowDimensions(); - const {canUseReportFields} = usePermissions(); const isSettled = ReportUtils.isSettled(report.reportID); const isTotalUpdated = ReportUtils.hasUpdatedTotal(report); @@ -69,7 +67,7 @@ function MoneyReportView({report, policy, policyReportFields, shouldShowHorizont - {canUseReportFields && + {ReportUtils.reportFieldsEnabled(report) && sortedPolicyReportFields.map((reportField) => { const isTitleField = ReportUtils.isReportFieldOfTypeTitle(reportField); const fieldValue = isTitleField ? report.reportName : reportField.value ?? reportField.defaultValue; diff --git a/src/components/ReportActionItem/MoneyRequestView.tsx b/src/components/ReportActionItem/MoneyRequestView.tsx index 3fa6f1872ca3..f0956da948c9 100644 --- a/src/components/ReportActionItem/MoneyRequestView.tsx +++ b/src/components/ReportActionItem/MoneyRequestView.tsx @@ -110,6 +110,7 @@ function MoneyRequestView({ const formattedOriginalAmount = transactionOriginalAmount && transactionOriginalCurrency && CurrencyUtils.convertToDisplayString(transactionOriginalAmount, transactionOriginalCurrency); const isCardTransaction = TransactionUtils.isCardTransaction(transaction); const cardProgramName = isCardTransaction && transactionCardID !== undefined ? CardUtils.getCardDescription(transactionCardID) : ''; + const isApproved = ReportUtils.isReportApproved(moneyRequestReport); // Flags for allowing or disallowing editing a money request const isSettled = ReportUtils.isSettled(moneyRequestReport?.reportID); @@ -173,7 +174,7 @@ function MoneyRequestView({ if (!isDistanceRequest) { amountDescription += ` • ${translate('iou.cash')}`; } - if (ReportUtils.isReportApproved(report)) { + if (isApproved) { amountDescription += ` • ${translate('iou.approved')}`; } else if (isCancelled) { amountDescription += ` • ${translate('iou.canceled')}`; @@ -267,13 +268,7 @@ function MoneyRequestView({ titleStyle={styles.flex1} onPress={() => Navigation.navigate( - ROUTES.MONEY_REQUEST_STEP_DESCRIPTION.getRoute( - CONST.IOU.ACTION.EDIT, - CONST.IOU.TYPE.REQUEST, - transaction?.transactionID ?? '', - report.reportID, - Navigation.getActiveRouteWithoutParams(), - ), + ROUTES.MONEY_REQUEST_STEP_DESCRIPTION.getRoute(CONST.IOU.ACTION.EDIT, CONST.IOU.TYPE.REQUEST, transaction?.transactionID ?? '', report.reportID), ) } wrapperStyle={[styles.pv2, styles.taskDescriptionMenuItem]} diff --git a/src/components/SelectionList/types.ts b/src/components/SelectionList/types.ts index b8a92820e6c0..be871971ee92 100644 --- a/src/components/SelectionList/types.ts +++ b/src/components/SelectionList/types.ts @@ -55,7 +55,7 @@ type User = { login?: string; /** Element to show on the right side of the item */ - rightElement?: ReactElement; + rightElement?: ReactNode; /** Icons for the user (can be multiple if it's a Workspace) */ icons?: Icon[]; @@ -129,6 +129,9 @@ type Section = { /** Whether this section items disabled for selection */ isDisabled?: boolean; + + /** Whether this section should be shown or not */ + shouldShow?: boolean; }; type BaseSelectionListProps = Partial & { diff --git a/src/languages/en.ts b/src/languages/en.ts index cd1797d78220..f24b0e3e2438 100755 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -719,6 +719,10 @@ export default { subtitle: 'These details are used for travel and payments. They are never shown on your public profile.', }, }, + shareCodePage: { + title: 'Your code', + subtitle: 'Invite members to Expensify by sharing your personal QR code or referral link.', + }, loungeAccessPage: { loungeAccess: 'Lounge access', headline: 'The Expensify Lounge is closed.', @@ -1539,7 +1543,7 @@ export default { travel: 'Travel', members: 'Members', plan: 'Plan', - overview: 'Overview', + profile: 'Profile', bankAccount: 'Bank account', connectBankAccount: 'Connect bank account', testTransactions: 'Test transactions', @@ -1679,7 +1683,6 @@ export default { nameInputLabel: 'Name', nameInputHelpText: 'This is the name you will see on your workspace.', nameIsRequiredError: 'You need to define a name for your workspace.', - nameIsTooLongError: `Your workspace name can be at most ${CONST.WORKSPACE_NAME_CHARACTER_LIMIT} characters long.`, currencyInputLabel: 'Default currency', currencyInputHelpText: 'All expenses on this workspace will be converted to this currency.', currencyInputDisabledText: "The default currency can't be changed because this workspace is linked to a USD bank account.", @@ -1984,7 +1987,7 @@ export default { parentNavigationSummary: ({rootReportName, workspaceName}: ParentNavigationSummaryParams) => `From ${rootReportName}${workspaceName ? ` in ${workspaceName}` : ''}`, }, qrCodes: { - copy: 'Copy', + copy: 'Copy URL', copied: 'Copied!', }, moderation: { diff --git a/src/languages/es.ts b/src/languages/es.ts index f61abda6cd4d..3238e7fa94b0 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -713,6 +713,10 @@ export default { subtitle: 'Estos detalles se utilizan para viajes y pagos. Nunca se mostrarán en tu perfil público.', }, }, + shareCodePage: { + title: 'Tu código', + subtitle: 'Invita a miembros a Expensify compartiendo tu código QR personal o enlace de invitación.', + }, loungeAccessPage: { loungeAccess: 'Acceso a la sala vip', headline: 'La sala vip de Expensify está cerrada.', @@ -1561,7 +1565,7 @@ export default { travel: 'Viajes', members: 'Miembros', plan: 'Plan', - overview: 'Descripción', + profile: 'Perfil', bankAccount: 'Cuenta bancaria', connectBankAccount: 'Conectar cuenta bancaria', testTransactions: 'Transacciones de prueba', @@ -1702,7 +1706,6 @@ export default { nameInputLabel: 'Nombre', nameInputHelpText: 'Este es el nombre que verás en tu espacio de trabajo.', nameIsRequiredError: 'Debes definir un nombre para tu espacio de trabajo.', - nameIsTooLongError: `El nombre de su espacio de trabajo no puede tener más de ${CONST.WORKSPACE_NAME_CHARACTER_LIMIT} caracteres.`, currencyInputLabel: 'Moneda por defecto', currencyInputHelpText: 'Todas los gastos en este espacio de trabajo serán convertidos a esta moneda.', currencyInputDisabledText: 'La moneda predeterminada no se puede cambiar porque este espacio de trabajo está vinculado a una cuenta bancaria en USD.', @@ -2470,7 +2473,7 @@ export default { parentNavigationSummary: ({rootReportName, workspaceName}: ParentNavigationSummaryParams) => `De ${rootReportName}${workspaceName ? ` en ${workspaceName}` : ''}`, }, qrCodes: { - copy: 'Copiar', + copy: 'Copiar URL', copied: '¡Copiado!', }, actionableMentionWhisperOptions: { diff --git a/src/libs/Navigation/AppNavigator/ModalStackNavigators.tsx b/src/libs/Navigation/AppNavigator/ModalStackNavigators.tsx index c7be135e8b57..b0ec6d1f3a94 100644 --- a/src/libs/Navigation/AppNavigator/ModalStackNavigators.tsx +++ b/src/libs/Navigation/AppNavigator/ModalStackNavigators.tsx @@ -242,7 +242,7 @@ const SettingsModalStackNavigator = createModalStackNavigator 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, - [SCREENS.WORKSPACE.CURRENCY]: () => require('../../../pages/workspace/WorkspaceOverviewCurrencyPage').default as React.ComponentType, + [SCREENS.WORKSPACE.CURRENCY]: () => require('../../../pages/workspace/WorkspaceProfileCurrencyPage').default as React.ComponentType, [SCREENS.REIMBURSEMENT_ACCOUNT]: () => require('../../../pages/ReimbursementAccount/ReimbursementAccountPage').default as React.ComponentType, [SCREENS.GET_ASSISTANCE]: () => require('../../../pages/GetAssistancePage').default as React.ComponentType, [SCREENS.SETTINGS.TWO_FACTOR_AUTH]: () => require('../../../pages/settings/Security/TwoFactorAuth/TwoFactorAuthPage').default as React.ComponentType, diff --git a/src/libs/Navigation/AppNavigator/Navigators/CentralPaneNavigator/BaseCentralPaneNavigator.tsx b/src/libs/Navigation/AppNavigator/Navigators/CentralPaneNavigator/BaseCentralPaneNavigator.tsx index 53928b71be4e..087e963b3892 100644 --- a/src/libs/Navigation/AppNavigator/Navigators/CentralPaneNavigator/BaseCentralPaneNavigator.tsx +++ b/src/libs/Navigation/AppNavigator/Navigators/CentralPaneNavigator/BaseCentralPaneNavigator.tsx @@ -15,7 +15,7 @@ type Screens = Partial React.C const workspaceSettingsScreens = { [SCREENS.SETTINGS.WORKSPACES]: () => require('../../../../../pages/workspace/WorkspacesListPage').default as React.ComponentType, - [SCREENS.WORKSPACE.OVERVIEW]: () => require('../../../../../pages/workspace/WorkspaceOverviewPage').default as React.ComponentType, + [SCREENS.WORKSPACE.PROFILE]: () => require('../../../../../pages/workspace/WorkspaceProfilePage').default as React.ComponentType, [SCREENS.WORKSPACE.CARD]: () => require('../../../../../pages/workspace/card/WorkspaceCardPage').default as React.ComponentType, [SCREENS.WORKSPACE.REIMBURSE]: () => require('../../../../../pages/workspace/reimburse/WorkspaceReimbursePage').default as React.ComponentType, [SCREENS.WORKSPACE.BILLS]: () => require('../../../../../pages/workspace/bills/WorkspaceBillsPage').default as React.ComponentType, diff --git a/src/libs/Navigation/AppNavigator/getPartialStateDiff.ts b/src/libs/Navigation/AppNavigator/getPartialStateDiff.ts index a22185422a11..b0825b4b2991 100644 --- a/src/libs/Navigation/AppNavigator/getPartialStateDiff.ts +++ b/src/libs/Navigation/AppNavigator/getPartialStateDiff.ts @@ -26,9 +26,9 @@ type GetPartialStateDiffReturnType = { * This function returns partial additive diff between the two states. * * Example: Let's start with state A on route /r/123. If the screen is wide we will have a HOME opened on bottom tab and REPORT on central pane. - * Now let's say we want to navigate to /workspace/345/overview. We will generate state B from this path. - * State B will have WORKSPACE_INITIAL on the bottom tab and WORKSPACE_OVERVIEW on the central pane. - * Now we will generate partial diff between state A and state B. The diff will tell us that we need to push WORKSPACE_INITIAL on the bottom tab and WORKSPACE_OVERVIEW on the central pane. + * Now let's say we want to navigate to /workspace/345/profile. We will generate state B from this path. + * State B will have WORKSPACE_INITIAL on the bottom tab and WORKSPACE_PROFILE on the central pane. + * Now we will generate partial diff between state A and state B. The diff will tell us that we need to push WORKSPACE_INITIAL on the bottom tab and WORKSPACE_PROFILE on the central pane. * * Then we can generate actions from this diff and dispatch them to the linkTo function. * 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 d61b36871434..d96ad416832d 100755 --- a/src/libs/Navigation/linkingConfig/CENTRAL_PANE_TO_RHP_MAPPING.ts +++ b/src/libs/Navigation/linkingConfig/CENTRAL_PANE_TO_RHP_MAPPING.ts @@ -2,7 +2,7 @@ import type {CentralPaneName} from '@libs/Navigation/types'; import SCREENS from '@src/SCREENS'; const CENTRAL_PANE_TO_RHP_MAPPING: Partial> = { - [SCREENS.WORKSPACE.OVERVIEW]: [SCREENS.WORKSPACE.NAME, SCREENS.WORKSPACE.CURRENCY], + [SCREENS.WORKSPACE.PROFILE]: [SCREENS.WORKSPACE.NAME, SCREENS.WORKSPACE.CURRENCY], [SCREENS.WORKSPACE.REIMBURSE]: [SCREENS.WORKSPACE.RATE_AND_UNIT], [SCREENS.WORKSPACE.MEMBERS]: [SCREENS.WORKSPACE.INVITE, SCREENS.WORKSPACE.INVITE_MESSAGE], }; diff --git a/src/libs/Navigation/linkingConfig/TAB_TO_CENTRAL_PANE_MAPPING.ts b/src/libs/Navigation/linkingConfig/TAB_TO_CENTRAL_PANE_MAPPING.ts index 3344cffe94ae..446fb479ea09 100755 --- a/src/libs/Navigation/linkingConfig/TAB_TO_CENTRAL_PANE_MAPPING.ts +++ b/src/libs/Navigation/linkingConfig/TAB_TO_CENTRAL_PANE_MAPPING.ts @@ -5,7 +5,7 @@ const TAB_TO_CENTRAL_PANE_MAPPING: Record = { [SCREENS.HOME]: [SCREENS.REPORT], [SCREENS.ALL_SETTINGS]: [SCREENS.SETTINGS.WORKSPACES], [SCREENS.WORKSPACE.INITIAL]: [ - SCREENS.WORKSPACE.OVERVIEW, + SCREENS.WORKSPACE.PROFILE, SCREENS.WORKSPACE.CARD, SCREENS.WORKSPACE.REIMBURSE, SCREENS.WORKSPACE.BILLS, diff --git a/src/libs/Navigation/linkingConfig/config.ts b/src/libs/Navigation/linkingConfig/config.ts index 12577e360784..ab218adc3879 100644 --- a/src/libs/Navigation/linkingConfig/config.ts +++ b/src/libs/Navigation/linkingConfig/config.ts @@ -42,7 +42,7 @@ const config: LinkingOptions['config'] = { [SCREENS.REPORT]: ROUTES.REPORT_WITH_ID.route, [SCREENS.SETTINGS.WORKSPACES]: ROUTES.SETTINGS_WORKSPACES, - [SCREENS.WORKSPACE.OVERVIEW]: ROUTES.WORKSPACE_OVERVIEW.route, + [SCREENS.WORKSPACE.PROFILE]: ROUTES.WORKSPACE_PROFILE.route, [SCREENS.WORKSPACE.CARD]: { path: ROUTES.WORKSPACE_CARD.route, }, @@ -224,7 +224,7 @@ const config: LinkingOptions['config'] = { path: ROUTES.SETTINGS_STATUS_CLEAR_AFTER_TIME, }, [SCREENS.WORKSPACE.CURRENCY]: { - path: ROUTES.WORKSPACE_OVERVIEW_CURRENCY.route, + path: ROUTES.WORKSPACE_PROFILE_CURRENCY.route, }, [SCREENS.WORKSPACE.RATE_AND_UNIT]: { path: ROUTES.WORKSPACE_RATE_AND_UNIT.route, @@ -245,7 +245,7 @@ const config: LinkingOptions['config'] = { [SCREENS.KEYBOARD_SHORTCUTS]: { path: ROUTES.KEYBOARD_SHORTCUTS, }, - [SCREENS.WORKSPACE.NAME]: ROUTES.WORKSPACE_OVERVIEW_NAME.route, + [SCREENS.WORKSPACE.NAME]: ROUTES.WORKSPACE_PROFILE_NAME.route, }, }, [SCREENS.RIGHT_MODAL.PRIVATE_NOTES]: { diff --git a/src/libs/Navigation/switchPolicyID.ts b/src/libs/Navigation/switchPolicyID.ts index 9afb325eee99..72a7c3e32fb4 100644 --- a/src/libs/Navigation/switchPolicyID.ts +++ b/src/libs/Navigation/switchPolicyID.ts @@ -122,9 +122,9 @@ export default function switchPolicyID(navigation: NavigationContainerRef): MemberForList; +function formatMemberForList(member: null | undefined, config?: Partial): undefined; +function formatMemberForList(member: ReportUtils.OptionData | null | undefined, config: Partial = {}): MemberForList | undefined { if (!member) { return undefined; } - const accountID = member.accountID; + const accountID = member.accountID ?? undefined; return { // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing @@ -2022,3 +2024,5 @@ export { formatSectionsFromSearchTerm, transformedTaxRates, }; + +export type {MemberForList}; diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index 747c44a60e0c..26280f95447d 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -1889,6 +1889,13 @@ function isReportFieldOfTypeTitle(reportField: OnyxEntry): bo return reportField?.type === 'formula' && reportField?.fieldID === CONST.REPORT_FIELD_TITLE_FIELD_ID; } +/** + * Check if report fields are available to use in a report + */ +function reportFieldsEnabled(report: Report) { + return Permissions.canUseReportFields(allBetas ?? []) && isPaidGroupPolicyExpenseReport(report); +} + /** * Given a report field, check if the field can be edited or not. * For title fields, its considered disabled if `deletable` prop is `true` (https://github.com/Expensify/App/issues/35043#issuecomment-1911275433) @@ -1946,7 +1953,7 @@ function getMoneyRequestReportName(report: OnyxEntry, policy: OnyxEntry< const reportFields = isReportSettled ? report?.reportFields : getReportFieldsByPolicyID(report?.policyID ?? ''); const titleReportField = getFormulaTypeReportField(reportFields ?? {}); - if (titleReportField && report?.reportName && Permissions.canUseReportFields(allBetas ?? [])) { + if (titleReportField && report?.reportName && reportFieldsEnabled(report)) { return report.reportName; } @@ -5034,6 +5041,7 @@ export { hasUpdatedTotal, isReportFieldDisabled, getAvailableReportFields, + reportFieldsEnabled, getAllAncestorReportActionIDs, }; diff --git a/src/libs/SessionUtils.ts b/src/libs/SessionUtils.ts index c73513c747af..52521d5146cc 100644 --- a/src/libs/SessionUtils.ts +++ b/src/libs/SessionUtils.ts @@ -4,7 +4,7 @@ import ONYXKEYS from '@src/ONYXKEYS'; /** * Determine if the transitioning user is logging in as a new user. */ -function isLoggingInAsNewUser(transitionURL: string, sessionEmail: string): boolean { +function isLoggingInAsNewUser(transitionURL?: string, sessionEmail?: string): boolean { // The OldDot mobile app does not URL encode the parameters, but OldDot web // does. We don't want to deploy OldDot mobile again, so as a work around we // compare the session email to both the decoded and raw email from the transition link. @@ -20,7 +20,7 @@ function isLoggingInAsNewUser(transitionURL: string, sessionEmail: string): bool // If they do not match it might be due to encoding, so check the raw value // Capture the un-encoded text in the email param const emailParamRegex = /[?&]email=([^&]*)/g; - const matches = emailParamRegex.exec(transitionURL); + const matches = emailParamRegex.exec(transitionURL ?? ''); const linkedEmail = matches?.[1] ?? null; return linkedEmail !== sessionEmail; } diff --git a/src/libs/actions/Policy.ts b/src/libs/actions/Policy.ts index 866206895d5e..498ce6918509 100644 --- a/src/libs/actions/Policy.ts +++ b/src/libs/actions/Policy.ts @@ -35,7 +35,19 @@ import * as ReportUtils from '@libs/ReportUtils'; import * as TransactionUtils from '@libs/TransactionUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import type {PersonalDetailsList, Policy, PolicyMember, PolicyTags, RecentlyUsedCategories, RecentlyUsedTags, ReimbursementAccount, Report, ReportAction, Transaction} from '@src/types/onyx'; +import type { + InvitedEmailsToAccountIDs, + PersonalDetailsList, + Policy, + PolicyMember, + PolicyTags, + RecentlyUsedCategories, + RecentlyUsedTags, + ReimbursementAccount, + Report, + ReportAction, + Transaction, +} from '@src/types/onyx'; import type {Errors} from '@src/types/onyx/OnyxCommon'; import type {CustomUnit} from '@src/types/onyx/Policy'; import type {EmptyObject} from '@src/types/utils/EmptyObject'; @@ -518,7 +530,7 @@ function removeMembers(accountIDs: number[], policyID: string) { * * @returns - object with onyxSuccessData, onyxOptimisticData, and optimisticReportIDs (map login to reportID) */ -function createPolicyExpenseChats(policyID: string, invitedEmailsToAccountIDs: Record, hasOutstandingChildRequest = false): WorkspaceMembersChats { +function createPolicyExpenseChats(policyID: string, invitedEmailsToAccountIDs: InvitedEmailsToAccountIDs, hasOutstandingChildRequest = false): WorkspaceMembersChats { const workspaceMembersChats: WorkspaceMembersChats = { onyxSuccessData: [], onyxOptimisticData: [], @@ -607,7 +619,7 @@ function createPolicyExpenseChats(policyID: string, invitedEmailsToAccountIDs: R /** * Adds members to the specified workspace/policyID */ -function addMembersToWorkspace(invitedEmailsToAccountIDs: Record, welcomeNote: string, policyID: string) { +function addMembersToWorkspace(invitedEmailsToAccountIDs: InvitedEmailsToAccountIDs, welcomeNote: string, policyID: string) { const membersListKey = `${ONYXKEYS.COLLECTION.POLICY_MEMBERS}${policyID}` as const; const logins = Object.keys(invitedEmailsToAccountIDs).map((memberLogin) => OptionsListUtils.addSMSDomainIfPhoneNumber(memberLogin)); const accountIDs = Object.values(invitedEmailsToAccountIDs); @@ -1499,7 +1511,7 @@ function openDraftWorkspaceRequest(policyID: string) { API.read(READ_COMMANDS.OPEN_DRAFT_WORKSPACE_REQUEST, params); } -function setWorkspaceInviteMembersDraft(policyID: string, invitedEmailsToAccountIDs: Record) { +function setWorkspaceInviteMembersDraft(policyID: string, invitedEmailsToAccountIDs: InvitedEmailsToAccountIDs) { Onyx.set(`${ONYXKEYS.COLLECTION.WORKSPACE_INVITE_MEMBERS_DRAFT}${policyID}`, invitedEmailsToAccountIDs); } diff --git a/src/libs/actions/Welcome.ts b/src/libs/actions/Welcome.ts index 3f6b2dc99a8f..952d19117679 100644 --- a/src/libs/actions/Welcome.ts +++ b/src/libs/actions/Welcome.ts @@ -136,11 +136,13 @@ function show(routes: NavigationState['routes'], showEngagem const transitionRoute = routes.find( (route): route is NavigationState>['routes'][number] => route.name === SCREENS.TRANSITION_BETWEEN_APPS, ); + const activeRoute = Navigation.getActiveRouteWithoutParams(); + const isOnWorkspaceOverviewPage = activeRoute?.startsWith('/workspace') && activeRoute?.endsWith('/overview'); const isExitingToWorkspaceRoute = transitionRoute?.params?.exitTo === 'workspace/new'; // If we already opened the workspace settings or want the admin room to stay open, do not // navigate away to the workspace chat report - const shouldNavigateToWorkspaceChat = !isExitingToWorkspaceRoute; + const shouldNavigateToWorkspaceChat = !isExitingToWorkspaceRoute && !isOnWorkspaceOverviewPage; const workspaceChatReport = Object.values(allReports ?? {}).find((report) => { if (report) { diff --git a/src/pages/LogOutPreviousUserPage.js b/src/pages/LogOutPreviousUserPage.js deleted file mode 100644 index 0d1bf4f47025..000000000000 --- a/src/pages/LogOutPreviousUserPage.js +++ /dev/null @@ -1,96 +0,0 @@ -import lodashGet from 'lodash/get'; -import PropTypes from 'prop-types'; -import React, {useContext, useEffect} from 'react'; -import {Linking, NativeModules} from 'react-native'; -import {withOnyx} from 'react-native-onyx'; -import FullScreenLoadingIndicator from '@components/FullscreenLoadingIndicator'; -import InitialUrlContext from '@libs/InitialUrlContext'; -import Log from '@libs/Log'; -import Navigation from '@libs/Navigation/Navigation'; -import * as SessionUtils from '@libs/SessionUtils'; -import * as Session from '@userActions/Session'; -import CONST from '@src/CONST'; -import ONYXKEYS from '@src/ONYXKEYS'; -import ROUTES from '@src/ROUTES'; - -const propTypes = { - /** The details about the account that the user is signing in with */ - account: PropTypes.shape({ - /** Whether the account data is loading */ - isLoading: PropTypes.bool, - }), - - /** The data about the current session which will be set once the user is authenticated and we return to this component as an AuthScreen */ - session: PropTypes.shape({ - /** The user's email for the current session */ - email: PropTypes.string, - }), -}; - -const defaultProps = { - account: { - isLoading: false, - }, - session: { - email: null, - }, -}; - -// This page is responsible for handling transitions from OldDot. Specifically, it logs the current user -// out if the transition is for another user. -// -// This component should not do any other navigation as that handled in App.setUpPoliciesAndNavigate -function LogOutPreviousUserPage(props) { - const initUrl = useContext(InitialUrlContext); - useEffect(() => { - Linking.getInitialURL().then((url) => { - const sessionEmail = props.session.email; - const transitionUrl = NativeModules.HybridAppModule ? CONST.DEEPLINK_BASE_URL + initUrl : url; - const isLoggingInAsNewUser = SessionUtils.isLoggingInAsNewUser(transitionUrl, sessionEmail); - - if (isLoggingInAsNewUser) { - Session.signOutAndRedirectToSignIn(); - } - - // We need to signin and fetch a new authToken, if a user was already authenticated in NewDot, and was redirected to OldDot - // and their authToken stored in Onyx becomes invalid. - // This workflow is triggered while setting up VBBA. User is redirected from NewDot to OldDot to set up 2FA, and then redirected back to NewDot - // On Enabling 2FA, authToken stored in Onyx becomes expired and hence we need to fetch new authToken - const shouldForceLogin = lodashGet(props, 'route.params.shouldForceLogin', '') === 'true'; - if (shouldForceLogin) { - Log.info('LogOutPreviousUserPage - forcing login with shortLivedAuthToken'); - const email = lodashGet(props, 'route.params.email', ''); - const shortLivedAuthToken = lodashGet(props, 'route.params.shortLivedAuthToken', ''); - Session.signInWithShortLivedAuthToken(email, shortLivedAuthToken); - } - - const exitTo = lodashGet(props, 'route.params.exitTo', ''); - // We don't want to navigate to the exitTo route when creating a new workspace from a deep link, - // because we already handle creating the optimistic policy and navigating to it in App.setUpPoliciesAndNavigate, - // which is already called when AuthScreens mounts. - if (exitTo && exitTo !== ROUTES.WORKSPACE_NEW && !props.account.isLoading && !isLoggingInAsNewUser) { - Navigation.isNavigationReady().then(() => { - // remove this screen and navigate to exit route - const exitUrl = NativeModules.HybridAppModule ? Navigation.parseHybridAppUrl(exitTo) : exitTo; - Navigation.goBack(); - Navigation.navigate(exitUrl); - }); - } - }); - }, [initUrl, props]); - - return ; -} - -LogOutPreviousUserPage.propTypes = propTypes; -LogOutPreviousUserPage.defaultProps = defaultProps; -LogOutPreviousUserPage.displayName = 'LogOutPreviousUserPage'; - -export default withOnyx({ - account: { - key: ONYXKEYS.ACCOUNT, - }, - session: { - key: ONYXKEYS.SESSION, - }, -})(LogOutPreviousUserPage); diff --git a/src/pages/LogOutPreviousUserPage.tsx b/src/pages/LogOutPreviousUserPage.tsx new file mode 100644 index 000000000000..f68344604dfa --- /dev/null +++ b/src/pages/LogOutPreviousUserPage.tsx @@ -0,0 +1,60 @@ +import type {StackScreenProps} from '@react-navigation/stack'; +import React, {useEffect} from 'react'; +import {Linking} from 'react-native'; +import {withOnyx} from 'react-native-onyx'; +import type {OnyxEntry} from 'react-native-onyx'; +import FullScreenLoadingIndicator from '@components/FullscreenLoadingIndicator'; +import * as SessionUtils from '@libs/SessionUtils'; +import type {AuthScreensParamList} from '@navigation/types'; +import * as SessionActions from '@userActions/Session'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type SCREENS from '@src/SCREENS'; +import type {Session} from '@src/types/onyx'; + +type LogOutPreviousUserPageOnyxProps = { + /** The data about the current session which will be set once the user is authenticated and we return to this component as an AuthScreen */ + session: OnyxEntry; +}; + +type LogOutPreviousUserPageProps = LogOutPreviousUserPageOnyxProps & StackScreenProps; + +// This page is responsible for handling transitions from OldDot. Specifically, it logs the current user +// out if the transition is for another user. +// +// This component should not do any other navigation as that handled in App.setUpPoliciesAndNavigate +function LogOutPreviousUserPage({session, route}: LogOutPreviousUserPageProps) { + useEffect(() => { + Linking.getInitialURL().then((transitionURL) => { + const sessionEmail = session?.email; + const isLoggingInAsNewUser = SessionUtils.isLoggingInAsNewUser(transitionURL ?? undefined, sessionEmail); + + if (isLoggingInAsNewUser) { + SessionActions.signOutAndRedirectToSignIn(); + } + + // We need to signin and fetch a new authToken, if a user was already authenticated in NewDot, and was redirected to OldDot + // and their authToken stored in Onyx becomes invalid. + // This workflow is triggered while setting up VBBA. User is redirected from NewDot to OldDot to set up 2FA, and then redirected back to NewDot + // On Enabling 2FA, authToken stored in Onyx becomes expired and hence we need to fetch new authToken + const shouldForceLogin = route.params.shouldForceLogin === 'true'; + if (shouldForceLogin) { + const email = route.params.email ?? ''; + const shortLivedAuthToken = route.params.shortLivedAuthToken ?? ''; + SessionActions.signInWithShortLivedAuthToken(email, shortLivedAuthToken); + } + }); + + // We only want to run this effect once on mount (when the page first loads after transitioning from OldDot) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return ; +} + +LogOutPreviousUserPage.displayName = 'LogOutPreviousUserPage'; + +export default withOnyx({ + session: { + key: ONYXKEYS.SESSION, + }, +})(LogOutPreviousUserPage); diff --git a/src/pages/OnboardEngagement/PurposeForUsingExpensifyPage.tsx b/src/pages/OnboardEngagement/PurposeForUsingExpensifyPage.tsx index 66d25da28f9d..025dcafd9740 100644 --- a/src/pages/OnboardEngagement/PurposeForUsingExpensifyPage.tsx +++ b/src/pages/OnboardEngagement/PurposeForUsingExpensifyPage.tsx @@ -90,7 +90,7 @@ function PurposeForUsingExpensifyModal() { } Report.completeEngagementModal(message, choice); - Report.navigateToConciergeChat(); + Report.navigateToConciergeChat(false, true); }, []); const menuItems: MenuItemProps[] = useMemo( diff --git a/src/pages/RoomInvitePage.js b/src/pages/RoomInvitePage.js index 3f55ad6c0ff7..9c43a1820aa9 100644 --- a/src/pages/RoomInvitePage.js +++ b/src/pages/RoomInvitePage.js @@ -86,7 +86,7 @@ function RoomInvitePage(props) { // Update selectedOptions with the latest personalDetails information const detailsMap = {}; - _.forEach(inviteOptions.personalDetails, (detail) => (detailsMap[detail.login] = OptionsListUtils.formatMemberForList(detail, false))); + _.forEach(inviteOptions.personalDetails, (detail) => (detailsMap[detail.login] = OptionsListUtils.formatMemberForList(detail))); const newSelectedOptions = []; _.forEach(selectedOptions, (option) => { newSelectedOptions.push(_.has(detailsMap, option.login) ? {...detailsMap[option.login], isSelected: true} : option); @@ -142,7 +142,7 @@ function RoomInvitePage(props) { // Filtering out selected users from the search results const selectedLogins = _.map(selectedOptions, ({login}) => login); const personalDetailsWithoutSelected = _.filter(personalDetails, ({login}) => !_.contains(selectedLogins, login)); - const personalDetailsFormatted = _.map(personalDetailsWithoutSelected, (personalDetail) => OptionsListUtils.formatMemberForList(personalDetail, false)); + const personalDetailsFormatted = _.map(personalDetailsWithoutSelected, (personalDetail) => OptionsListUtils.formatMemberForList(personalDetail)); const hasUnselectedUserToInvite = userToInvite && !_.contains(selectedLogins, userToInvite.login); sectionsArr.push({ @@ -156,7 +156,7 @@ function RoomInvitePage(props) { if (hasUnselectedUserToInvite) { sectionsArr.push({ title: undefined, - data: [OptionsListUtils.formatMemberForList(userToInvite, false)], + data: [OptionsListUtils.formatMemberForList(userToInvite)], shouldShow: true, indexOffset, }); diff --git a/src/pages/ShareCodePage.tsx b/src/pages/ShareCodePage.tsx index 1752c8d4a1a3..7df92875b0ac 100644 --- a/src/pages/ShareCodePage.tsx +++ b/src/pages/ShareCodePage.tsx @@ -11,6 +11,7 @@ import MenuItem from '@components/MenuItem'; import QRShareWithDownload from '@components/QRShare/QRShareWithDownload'; import type QRShareWithDownloadHandle from '@components/QRShare/QRShareWithDownload/types'; import ScreenWrapper from '@components/ScreenWrapper'; +import Section from '@components/Section'; import withCurrentUserPersonalDetails from '@components/withCurrentUserPersonalDetails'; import type {WithCurrentUserPersonalDetailsProps} from '@components/withCurrentUserPersonalDetails'; import useEnvironment from '@hooks/useEnvironment'; @@ -82,45 +83,60 @@ function ShareCodePage({report, session, currentUserPersonalDetails}: ShareCodeP shouldShowBackButton={isReport || isSmallScreenWidth} icon={Illustrations.QrCode} /> - - - - + + +
+ + + - - Clipboard.setString(url)} - shouldLimitWidth={false} - /> + + Clipboard.setString(url)} + shouldLimitWidth={false} + wrapperStyle={themeStyles.sectionMenuItemTopDescription} + /> - {isNative && ( - qrCodeRef.current?.download?.()} - /> - )} + {isNative && ( + qrCodeRef.current?.download?.()} + wrapperStyle={themeStyles.sectionMenuItemTopDescription} + /> + )} - Navigation.navigate(ROUTES.REFERRAL_DETAILS_MODAL.getRoute(CONST.REFERRAL_PROGRAM.CONTENT_TYPES.SHARE_CODE))} - /> + Navigation.navigate(ROUTES.REFERRAL_DETAILS_MODAL.getRoute(CONST.REFERRAL_PROGRAM.CONTENT_TYPES.SHARE_CODE))} + wrapperStyle={themeStyles.sectionMenuItemTopDescription} + shouldShowRightIcon + /> + +
diff --git a/src/pages/iou/request/step/IOURequestStepDescription.js b/src/pages/iou/request/step/IOURequestStepDescription.js index 3a50eff13918..f3324a311732 100644 --- a/src/pages/iou/request/step/IOURequestStepDescription.js +++ b/src/pages/iou/request/step/IOURequestStepDescription.js @@ -14,6 +14,7 @@ import transactionPropTypes from '@components/transactionPropTypes'; import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; import compose from '@libs/compose'; +import * as ErrorUtils from '@libs/ErrorUtils'; import Navigation from '@libs/Navigation/Navigation'; import updateMultilineInputRange from '@libs/updateMultilineInputRange'; import * as IOU from '@userActions/IOU'; @@ -88,6 +89,24 @@ function IOURequestStepDescription({ }, []), ); + /** + * @param {Object} values + * @param {String} values.title + * @returns {Object} - An object containing the errors for each inputID + */ + const validate = useCallback((values) => { + const errors = {}; + + if (values.moneyRequestComment.length > CONST.DESCRIPTION_LIMIT) { + ErrorUtils.addErrorMessage(errors, 'moneyRequestComment', [ + 'common.error.characterLimitExceedCounter', + {length: values.moneyRequestComment.length, limit: CONST.DESCRIPTION_LIMIT}, + ]); + } + + return errors; + }, []); + const navigateBack = () => { Navigation.goBack(backTo); }; @@ -132,6 +151,7 @@ function IOURequestStepDescription({ style={[styles.flexGrow1, styles.ph5]} formID={ONYXKEYS.FORMS.MONEY_REQUEST_DESCRIPTION_FORM} onSubmit={updateComment} + validate={validate} submitButtonText={translate('common.save')} enabledWhenOffline > diff --git a/src/pages/settings/Profile/PersonalDetails/LegalNamePage.js b/src/pages/settings/Profile/PersonalDetails/LegalNamePage.js index edb54534b76c..e45253462b60 100644 --- a/src/pages/settings/Profile/PersonalDetails/LegalNamePage.js +++ b/src/pages/settings/Profile/PersonalDetails/LegalNamePage.js @@ -58,25 +58,23 @@ function LegalNamePage(props) { ErrorUtils.addErrorMessage(errors, 'legalFirstName', 'privatePersonalDetails.error.hasInvalidCharacter'); } else if (_.isEmpty(values.legalFirstName)) { errors.legalFirstName = 'common.error.fieldRequired'; + } else if (values.legalFirstName.length > CONST.TITLE_CHARACTER_LIMIT) { + ErrorUtils.addErrorMessage(errors, 'legalFirstName', ['common.error.characterLimitExceedCounter', {length: values.legalFirstName.length, limit: CONST.TITLE_CHARACTER_LIMIT}]); } if (ValidationUtils.doesContainReservedWord(values.legalFirstName, CONST.DISPLAY_NAME.RESERVED_NAMES)) { ErrorUtils.addErrorMessage(errors, 'legalFirstName', 'personalDetails.error.containsReservedWord'); } - if (values.legalFirstName.length > CONST.LEGAL_NAME.MAX_LENGTH) { - ErrorUtils.addErrorMessage(errors, 'legalFirstName', ['common.error.characterLimitExceedCounter', {length: values.legalFirstName.length, limit: CONST.LEGAL_NAME.MAX_LENGTH}]); - } if (!ValidationUtils.isValidLegalName(values.legalLastName)) { ErrorUtils.addErrorMessage(errors, 'legalLastName', 'privatePersonalDetails.error.hasInvalidCharacter'); } else if (_.isEmpty(values.legalLastName)) { errors.legalLastName = 'common.error.fieldRequired'; + } else if (values.legalLastName.length > CONST.TITLE_CHARACTER_LIMIT) { + ErrorUtils.addErrorMessage(errors, 'legalLastName', ['common.error.characterLimitExceedCounter', {length: values.legalLastName.length, limit: CONST.TITLE_CHARACTER_LIMIT}]); } if (ValidationUtils.doesContainReservedWord(values.legalLastName, CONST.DISPLAY_NAME.RESERVED_NAMES)) { ErrorUtils.addErrorMessage(errors, 'legalLastName', 'personalDetails.error.containsReservedWord'); } - if (values.legalLastName.length > CONST.LEGAL_NAME.MAX_LENGTH) { - ErrorUtils.addErrorMessage(errors, 'legalLastName', ['common.error.characterLimitExceedCounter', {length: values.legalLastName.length, limit: CONST.LEGAL_NAME.MAX_LENGTH}]); - } return errors; }, []); @@ -111,7 +109,6 @@ function LegalNamePage(props) { aria-label={props.translate('privatePersonalDetails.legalFirstName')} role={CONST.ROLE.PRESENTATION} defaultValue={legalFirstName} - maxLength={CONST.LEGAL_NAME.MAX_LENGTH + CONST.SEARCH_MAX_LENGTH} spellCheck={false} />
@@ -124,7 +121,6 @@ function LegalNamePage(props) { aria-label={props.translate('privatePersonalDetails.legalLastName')} role={CONST.ROLE.PRESENTATION} defaultValue={legalLastName} - maxLength={CONST.LEGAL_NAME.MAX_LENGTH + CONST.SEARCH_MAX_LENGTH} spellCheck={false} />
diff --git a/src/pages/settings/Profile/ProfilePage.js b/src/pages/settings/Profile/ProfilePage.js index b5e77ab438f5..2872fae5b511 100755 --- a/src/pages/settings/Profile/ProfilePage.js +++ b/src/pages/settings/Profile/ProfilePage.js @@ -168,7 +168,7 @@ function ProfilePage(props) { shouldShowBackButton={props.isSmallScreenWidth} icon={Illustrations.Profile} /> - +
CONST.TITLE_CHARACTER_LIMIT) { + ErrorUtils.addErrorMessage(errors, 'roomName', ['common.error.characterLimitExceedCounter', {length: values.roomName.length, limit: CONST.TITLE_CHARACTER_LIMIT}]); } return errors; diff --git a/src/pages/tasks/NewTaskDescriptionPage.js b/src/pages/tasks/NewTaskDescriptionPage.js index 4d84cac90537..54b0094dfcf7 100644 --- a/src/pages/tasks/NewTaskDescriptionPage.js +++ b/src/pages/tasks/NewTaskDescriptionPage.js @@ -12,6 +12,7 @@ import withLocalize, {withLocalizePropTypes} from '@components/withLocalize'; import useAutoFocusInput from '@hooks/useAutoFocusInput'; import useThemeStyles from '@hooks/useThemeStyles'; import compose from '@libs/compose'; +import * as ErrorUtils from '@libs/ErrorUtils'; import Navigation from '@libs/Navigation/Navigation'; import updateMultilineInputRange from '@libs/updateMultilineInputRange'; import * as Task from '@userActions/Task'; @@ -46,6 +47,20 @@ function NewTaskDescriptionPage(props) { Navigation.goBack(ROUTES.NEW_TASK); }; + /** + * @param {Object} values - form input values passed by the Form component + * @returns {Boolean} + */ + function validate(values) { + const errors = {}; + + if (values.taskDescription.length > CONST.DESCRIPTION_LIMIT) { + ErrorUtils.addErrorMessage(errors, 'taskDescription', ['common.error.characterLimitExceedCounter', {length: values.taskDescription.length, limit: CONST.DESCRIPTION_LIMIT}]); + } + + return errors; + } + return ( validate(values)} onSubmit={(values) => onSubmit(values)} enabledWhenOffline > diff --git a/src/pages/tasks/NewTaskDetailsPage.js b/src/pages/tasks/NewTaskDetailsPage.js index 4f4f2560a0d9..9595e1adbe76 100644 --- a/src/pages/tasks/NewTaskDetailsPage.js +++ b/src/pages/tasks/NewTaskDetailsPage.js @@ -57,6 +57,11 @@ function NewTaskDetailsPage(props) { if (!values.taskTitle) { // We error if the user doesn't enter a task name ErrorUtils.addErrorMessage(errors, 'taskTitle', 'newTaskPage.pleaseEnterTaskName'); + } else if (values.taskTitle.length > CONST.TITLE_CHARACTER_LIMIT) { + ErrorUtils.addErrorMessage(errors, 'taskTitle', ['common.error.characterLimitExceedCounter', {length: values.taskTitle.length, limit: CONST.TITLE_CHARACTER_LIMIT}]); + } + if (values.taskDescription.length > CONST.DESCRIPTION_LIMIT) { + ErrorUtils.addErrorMessage(errors, 'taskDescription', ['common.error.characterLimitExceedCounter', {length: values.taskDescription.length, limit: CONST.DESCRIPTION_LIMIT}]); } return errors; diff --git a/src/pages/tasks/NewTaskTitlePage.js b/src/pages/tasks/NewTaskTitlePage.js index 7bf6065625c0..e6da2a06435d 100644 --- a/src/pages/tasks/NewTaskTitlePage.js +++ b/src/pages/tasks/NewTaskTitlePage.js @@ -48,6 +48,8 @@ function NewTaskTitlePage(props) { if (!values.taskTitle) { // We error if the user doesn't enter a task name ErrorUtils.addErrorMessage(errors, 'taskTitle', 'newTaskPage.pleaseEnterTaskName'); + } else if (values.taskTitle.length > CONST.TITLE_CHARACTER_LIMIT) { + ErrorUtils.addErrorMessage(errors, 'taskTitle', ['common.error.characterLimitExceedCounter', {length: values.taskTitle.length, limit: CONST.TITLE_CHARACTER_LIMIT}]); } return errors; diff --git a/src/pages/tasks/TaskDescriptionPage.js b/src/pages/tasks/TaskDescriptionPage.js index 48be7022b187..add2cf3da057 100644 --- a/src/pages/tasks/TaskDescriptionPage.js +++ b/src/pages/tasks/TaskDescriptionPage.js @@ -13,6 +13,7 @@ import withCurrentUserPersonalDetails from '@components/withCurrentUserPersonalD import withLocalize, {withLocalizePropTypes} from '@components/withLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; import compose from '@libs/compose'; +import * as ErrorUtils from '@libs/ErrorUtils'; import Navigation from '@libs/Navigation/Navigation'; import * as ReportUtils from '@libs/ReportUtils'; import StringUtils from '@libs/StringUtils'; @@ -38,7 +39,20 @@ const defaultProps = { const parser = new ExpensiMark(); function TaskDescriptionPage(props) { const styles = useThemeStyles(); - const validate = useCallback(() => ({}), []); + + /** + * @param {Object} values - form input values passed by the Form component + * @returns {Boolean} + */ + const validate = useCallback((values) => { + const errors = {}; + + if (values.description.length > CONST.DESCRIPTION_LIMIT) { + ErrorUtils.addErrorMessage(errors, 'description', ['common.error.characterLimitExceedCounter', {length: values.description.length, limit: CONST.DESCRIPTION_LIMIT}]); + } + + return errors; + }, []); const submit = useCallback( (values) => { diff --git a/src/pages/tasks/TaskTitlePage.js b/src/pages/tasks/TaskTitlePage.js index 9b3d28a0d032..9fd1f29d3a0d 100644 --- a/src/pages/tasks/TaskTitlePage.js +++ b/src/pages/tasks/TaskTitlePage.js @@ -12,6 +12,7 @@ import withCurrentUserPersonalDetails from '@components/withCurrentUserPersonalD import withLocalize, {withLocalizePropTypes} from '@components/withLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; import compose from '@libs/compose'; +import * as ErrorUtils from '@libs/ErrorUtils'; import Navigation from '@libs/Navigation/Navigation'; import * as ReportUtils from '@libs/ReportUtils'; import withReportOrNotFound from '@pages/home/report/withReportOrNotFound'; @@ -44,6 +45,8 @@ function TaskTitlePage(props) { if (_.isEmpty(values.title)) { errors.title = 'newTaskPage.pleaseEnterTaskName'; + } else if (values.title.length > CONST.TITLE_CHARACTER_LIMIT) { + ErrorUtils.addErrorMessage(errors, 'title', ['common.error.characterLimitExceedCounter', {length: values.title.length, limit: CONST.TITLE_CHARACTER_LIMIT}]); } return errors; diff --git a/src/pages/workspace/WorkspaceInitialPage.tsx b/src/pages/workspace/WorkspaceInitialPage.tsx index ea243c56ac76..3f7bfc92d48d 100644 --- a/src/pages/workspace/WorkspaceInitialPage.tsx +++ b/src/pages/workspace/WorkspaceInitialPage.tsx @@ -150,11 +150,11 @@ function WorkspaceInitialPage({policyDraft, policy: policyProp, policyMembers, r const menuItems: WorkspaceMenuItem[] = [ { - translationKey: 'workspace.common.overview', + translationKey: 'workspace.common.profile', icon: Expensicons.Home, - action: singleExecution(waitForNavigate(() => Navigation.navigate(ROUTES.WORKSPACE_OVERVIEW.getRoute(policyID)))), + action: singleExecution(waitForNavigate(() => Navigation.navigate(ROUTES.WORKSPACE_PROFILE.getRoute(policyID)))), brickRoadIndicator: hasGeneralSettingsError ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined, - routeName: SCREENS.WORKSPACE.OVERVIEW, + routeName: SCREENS.WORKSPACE.PROFILE, }, ...(shouldShowProtectedItems ? protectedMenuItems : []), ]; diff --git a/src/pages/workspace/WorkspaceInviteMessagePage.js b/src/pages/workspace/WorkspaceInviteMessagePage.tsx similarity index 64% rename from src/pages/workspace/WorkspaceInviteMessagePage.js rename to src/pages/workspace/WorkspaceInviteMessagePage.tsx index bd5de51e0503..2e43708ed3ce 100644 --- a/src/pages/workspace/WorkspaceInviteMessagePage.js +++ b/src/pages/workspace/WorkspaceInviteMessagePage.tsx @@ -1,130 +1,110 @@ -import lodashGet from 'lodash/get'; -import PropTypes from 'prop-types'; +import type {StackScreenProps} from '@react-navigation/stack'; +import lodashDebounce from 'lodash/debounce'; import React, {useEffect, useState} from 'react'; import {Keyboard, View} from 'react-native'; import {withOnyx} from 'react-native-onyx'; -import _ from 'underscore'; +import type {OnyxEntry} from 'react-native-onyx'; +import type {GestureResponderEvent} from 'react-native/Libraries/Types/CoreEventTypes'; import FullPageNotFoundView from '@components/BlockingViews/FullPageNotFoundView'; import FormProvider from '@components/Form/FormProvider'; import InputWrapper from '@components/Form/InputWrapper'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import MultipleAvatars from '@components/MultipleAvatars'; import PressableWithoutFeedback from '@components/Pressable/PressableWithoutFeedback'; +import type {AnimatedTextInputRef} from '@components/RNTextInput'; import ScreenWrapper from '@components/ScreenWrapper'; import Text from '@components/Text'; import TextInput from '@components/TextInput'; -import withNavigationFocus from '@components/withNavigationFocus'; import useAutoFocusInput from '@hooks/useAutoFocusInput'; import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; -import compose from '@libs/compose'; import Navigation from '@libs/Navigation/Navigation'; import * as OptionsListUtils from '@libs/OptionsListUtils'; import * as PolicyUtils from '@libs/PolicyUtils'; import updateMultilineInputRange from '@libs/updateMultilineInputRange'; +import type {SettingsNavigatorParamList} from '@navigation/types'; import * as Link from '@userActions/Link'; import * as Policy from '@userActions/Policy'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; +import type SCREENS from '@src/SCREENS'; +import type {InvitedEmailsToAccountIDs, PersonalDetailsList} from '@src/types/onyx'; +import type {Errors} from '@src/types/onyx/OnyxCommon'; +import {isEmptyObject} from '@src/types/utils/EmptyObject'; import SearchInputManager from './SearchInputManager'; -import {policyDefaultProps, policyPropTypes} from './withPolicy'; import withPolicyAndFullscreenLoading from './withPolicyAndFullscreenLoading'; +import type {WithPolicyAndFullscreenLoadingProps} from './withPolicyAndFullscreenLoading'; -const personalDetailsPropTypes = PropTypes.shape({ - /** The accountID of the person */ - accountID: PropTypes.number.isRequired, - - /** The login of the person (either email or phone number) */ - login: PropTypes.string, - - /** The URL of the person's avatar (there should already be a default avatar if - the person doesn't have their own avatar uploaded yet, except for anon users) */ - avatar: PropTypes.string, - - /** This is either the user's full name, or their login if full name is an empty string */ - displayName: PropTypes.string, -}); - -const propTypes = { +type WorkspaceInviteMessagePageOnyxProps = { /** All of the personal details for everyone */ - allPersonalDetails: PropTypes.objectOf(personalDetailsPropTypes), - - invitedEmailsToAccountIDsDraft: PropTypes.objectOf(PropTypes.number), + allPersonalDetails: OnyxEntry; - /** URL Route params */ - route: PropTypes.shape({ - /** Params from the URL path */ - params: PropTypes.shape({ - /** policyID passed via route: /workspace/:policyID/invite-message */ - policyID: PropTypes.string, - }), - }).isRequired, + /** An object containing the accountID for every invited user email */ + invitedEmailsToAccountIDsDraft: OnyxEntry; - ...policyPropTypes, + /** Updated workspace invite message */ + workspaceInviteMessageDraft: OnyxEntry; }; -const defaultProps = { - ...policyDefaultProps, - allPersonalDetails: {}, - invitedEmailsToAccountIDsDraft: {}, -}; +type WorkspaceInviteMessagePageProps = WithPolicyAndFullscreenLoadingProps & + WorkspaceInviteMessagePageOnyxProps & + StackScreenProps; -function WorkspaceInviteMessagePage(props) { +function WorkspaceInviteMessagePage({workspaceInviteMessageDraft, invitedEmailsToAccountIDsDraft, policy, route, allPersonalDetails}: WorkspaceInviteMessagePageProps) { const styles = useThemeStyles(); const {translate} = useLocalize(); - const [welcomeNote, setWelcomeNote] = useState(); + const [welcomeNote, setWelcomeNote] = useState(); const {inputCallbackRef} = useAutoFocusInput(); const getDefaultWelcomeNote = () => - props.workspaceInviteMessageDraft || + // workspaceInviteMessageDraft can be an empty string + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + workspaceInviteMessageDraft || translate('workspace.inviteMessage.welcomeNote', { - workspaceName: props.policy.name, + workspaceName: policy?.name ?? '', }); useEffect(() => { - if (!_.isEmpty(props.invitedEmailsToAccountIDsDraft)) { + if (!isEmptyObject(invitedEmailsToAccountIDsDraft)) { setWelcomeNote(getDefaultWelcomeNote()); return; } - Navigation.goBack(ROUTES.WORKSPACE_INVITE.getRoute(props.route.params.policyID), true); + Navigation.goBack(ROUTES.WORKSPACE_INVITE.getRoute(route.params.policyID), true); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); - const debouncedSaveDraft = _.debounce((newDraft) => { - Policy.setWorkspaceInviteMessageDraft(props.route.params.policyID, newDraft); + const debouncedSaveDraft = lodashDebounce((newDraft: string) => { + Policy.setWorkspaceInviteMessageDraft(route.params.policyID, newDraft); }); const sendInvitation = () => { Keyboard.dismiss(); - Policy.addMembersToWorkspace(props.invitedEmailsToAccountIDsDraft, welcomeNote, props.route.params.policyID); - Policy.setWorkspaceInviteMembersDraft(props.route.params.policyID, {}); + Policy.addMembersToWorkspace(invitedEmailsToAccountIDsDraft ?? {}, welcomeNote ?? '', route.params.policyID); + Policy.setWorkspaceInviteMembersDraft(route.params.policyID, {}); SearchInputManager.searchInput = ''; // Pop the invite message page before navigating to the members page. Navigation.goBack(); - Navigation.navigate(ROUTES.WORKSPACE_MEMBERS.getRoute(props.route.params.policyID)); + Navigation.navigate(ROUTES.WORKSPACE_MEMBERS.getRoute(route.params.policyID)); }; - /** - * Opens privacy url as an external link - * @param {Object} event - */ - const openPrivacyURL = (event) => { - event.preventDefault(); + /** Opens privacy url as an external link */ + const openPrivacyURL = (event: GestureResponderEvent | KeyboardEvent | undefined) => { + event?.preventDefault(); Link.openExternalLink(CONST.PRIVACY_URL); }; - const validate = () => { - const errorFields = {}; - if (_.isEmpty(props.invitedEmailsToAccountIDsDraft)) { + const validate = (): Errors => { + const errorFields: Errors = {}; + if (isEmptyObject(invitedEmailsToAccountIDsDraft)) { errorFields.welcomeMessage = 'workspace.inviteMessage.inviteNoMembersError'; } return errorFields; }; - const policyName = lodashGet(props.policy, 'name'); + const policyName = policy?.name; return ( Navigation.goBack(ROUTES.SETTINGS_WORKSPACES)} > Navigation.dismissModal()} - onBackButtonPress={() => Navigation.goBack(ROUTES.WORKSPACE_INVITE.getRoute(props.route.params.policyID))} + onBackButtonPress={() => Navigation.goBack(ROUTES.WORKSPACE_INVITE.getRoute(route.params.policyID))} /> { + onChangeText={(text: string) => { setWelcomeNote(text); debouncedSaveDraft(text); }} - ref={(el) => { - if (!el) { + ref={(element: AnimatedTextInputRef) => { + if (!element) { return; } - inputCallbackRef(el); - updateMultilineInputRange(el); + inputCallbackRef(element); + updateMultilineInputRange(element); }} /> @@ -210,13 +194,10 @@ function WorkspaceInviteMessagePage(props) { ); } -WorkspaceInviteMessagePage.propTypes = propTypes; -WorkspaceInviteMessagePage.defaultProps = defaultProps; WorkspaceInviteMessagePage.displayName = 'WorkspaceInviteMessagePage'; -export default compose( - withPolicyAndFullscreenLoading, - withOnyx({ +export default withPolicyAndFullscreenLoading( + withOnyx({ allPersonalDetails: { key: ONYXKEYS.PERSONAL_DETAILS_LIST, }, @@ -226,6 +207,5 @@ export default compose( workspaceInviteMessageDraft: { key: ({route}) => `${ONYXKEYS.COLLECTION.WORKSPACE_INVITE_MESSAGE_DRAFT}${route.params.policyID.toString()}`, }, - }), - withNavigationFocus, -)(WorkspaceInviteMessagePage); + })(WorkspaceInviteMessagePage), +); diff --git a/src/pages/workspace/WorkspaceInvitePage.js b/src/pages/workspace/WorkspaceInvitePage.tsx similarity index 57% rename from src/pages/workspace/WorkspaceInvitePage.js rename to src/pages/workspace/WorkspaceInvitePage.tsx index 8fcf425d8f34..ae139752a052 100644 --- a/src/pages/workspace/WorkspaceInvitePage.js +++ b/src/pages/workspace/WorkspaceInvitePage.tsx @@ -1,98 +1,87 @@ import {useNavigation} from '@react-navigation/native'; +import type {StackNavigationProp, StackScreenProps} from '@react-navigation/stack'; import Str from 'expensify-common/lib/str'; -import lodashGet from 'lodash/get'; -import PropTypes from 'prop-types'; import React, {useEffect, useMemo, useState} from 'react'; +import type {SectionListData} from 'react-native'; import {View} from 'react-native'; import {withOnyx} from 'react-native-onyx'; -import _ from 'underscore'; +import type {OnyxEntry} from 'react-native-onyx'; import FullPageNotFoundView from '@components/BlockingViews/FullPageNotFoundView'; import FormAlertWithSubmitButton from '@components/FormAlertWithSubmitButton'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import ScreenWrapper from '@components/ScreenWrapper'; import SelectionList from '@components/SelectionList'; +import type {Section} from '@components/SelectionList/types'; import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; import useThemeStyles from '@hooks/useThemeStyles'; -import compose from '@libs/compose'; import * as DeviceCapabilities from '@libs/DeviceCapabilities'; import * as LoginUtils from '@libs/LoginUtils'; import Navigation from '@libs/Navigation/Navigation'; import * as OptionsListUtils from '@libs/OptionsListUtils'; +import type {MemberForList} from '@libs/OptionsListUtils'; import {parsePhoneNumber} from '@libs/PhoneNumber'; import * as PolicyUtils from '@libs/PolicyUtils'; +import type {OptionData} from '@libs/ReportUtils'; +import type {SettingsNavigatorParamList} from '@navigation/types'; import * as Policy from '@userActions/Policy'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; +import type SCREENS from '@src/SCREENS'; +import type {Beta, InvitedEmailsToAccountIDs, PersonalDetailsList} from '@src/types/onyx'; +import type {Errors} from '@src/types/onyx/OnyxCommon'; +import {isEmptyObject} from '@src/types/utils/EmptyObject'; import SearchInputManager from './SearchInputManager'; -import {policyDefaultProps, policyPropTypes} from './withPolicy'; import withPolicyAndFullscreenLoading from './withPolicyAndFullscreenLoading'; +import type {WithPolicyAndFullscreenLoadingProps} from './withPolicyAndFullscreenLoading'; -const personalDetailsPropTypes = PropTypes.shape({ - /** The login of the person (either email or phone number) */ - login: PropTypes.string, +type MembersSection = SectionListData>; - /** The URL of the person's avatar (there should already be a default avatar if - the person doesn't have their own avatar uploaded yet, except for anon users) */ - avatar: PropTypes.string, - - /** This is either the user's full name, or their login if full name is an empty string */ - displayName: PropTypes.string, -}); +type WorkspaceInvitePageOnyxProps = { + /** All of the personal details for everyone */ + personalDetails: OnyxEntry; -const propTypes = { /** Beta features list */ - betas: PropTypes.arrayOf(PropTypes.string), - - /** All of the personal details for everyone */ - personalDetails: PropTypes.objectOf(personalDetailsPropTypes), - - /** URL Route params */ - route: PropTypes.shape({ - /** Params from the URL path */ - params: PropTypes.shape({ - /** policyID passed via route: /workspace/:policyID/invite */ - policyID: PropTypes.string, - }), - }).isRequired, - - isLoadingReportData: PropTypes.bool, - invitedEmailsToAccountIDsDraft: PropTypes.objectOf(PropTypes.number), - ...policyPropTypes, -}; + betas: OnyxEntry; -const defaultProps = { - personalDetails: {}, - betas: [], - isLoadingReportData: true, - invitedEmailsToAccountIDsDraft: {}, - ...policyDefaultProps, + /** An object containing the accountID for every invited user email */ + invitedEmailsToAccountIDsDraft: OnyxEntry; }; -function WorkspaceInvitePage(props) { +type WorkspaceInvitePageProps = WithPolicyAndFullscreenLoadingProps & WorkspaceInvitePageOnyxProps & StackScreenProps; + +function WorkspaceInvitePage({ + route, + policyMembers, + personalDetails: personalDetailsProp, + betas, + invitedEmailsToAccountIDsDraft, + policy, + isLoadingReportData = true, +}: WorkspaceInvitePageProps) { const styles = useThemeStyles(); const {translate} = useLocalize(); const [searchTerm, setSearchTerm] = useState(''); - const [selectedOptions, setSelectedOptions] = useState([]); - const [personalDetails, setPersonalDetails] = useState([]); - const [usersToInvite, setUsersToInvite] = useState([]); + const [selectedOptions, setSelectedOptions] = useState([]); + const [personalDetails, setPersonalDetails] = useState([]); + const [usersToInvite, setUsersToInvite] = useState([]); const [didScreenTransitionEnd, setDidScreenTransitionEnd] = useState(false); - const navigation = useNavigation(); + const navigation = useNavigation>(); const openWorkspaceInvitePage = () => { - const policyMemberEmailsToAccountIDs = PolicyUtils.getMemberAccountIDsForWorkspace(props.policyMembers, props.personalDetails); - Policy.openWorkspaceInvitePage(props.route.params.policyID, _.keys(policyMemberEmailsToAccountIDs)); + const policyMemberEmailsToAccountIDs = PolicyUtils.getMemberAccountIDsForWorkspace(policyMembers, personalDetailsProp); + Policy.openWorkspaceInvitePage(route.params.policyID, Object.keys(policyMemberEmailsToAccountIDs)); }; useEffect(() => { setSearchTerm(SearchInputManager.searchInput); return () => { - Policy.setWorkspaceInviteMembersDraft(props.route.params.policyID, {}); + Policy.setWorkspaceInviteMembersDraft(route.params.policyID, {}); }; - }, [props.route.params.policyID]); + }, [route.params.policyID]); useEffect(() => { - Policy.clearErrors(props.route.params.policyID); + Policy.clearErrors(route.params.policyID); openWorkspaceInvitePage(); // eslint-disable-next-line react-hooks/exhaustive-deps -- policyID changes remount the component }, []); @@ -111,57 +100,69 @@ function WorkspaceInvitePage(props) { useNetwork({onReconnect: openWorkspaceInvitePage}); - const excludedUsers = useMemo(() => PolicyUtils.getIneligibleInvitees(props.policyMembers, props.personalDetails), [props.policyMembers, props.personalDetails]); + const excludedUsers = useMemo(() => PolicyUtils.getIneligibleInvitees(policyMembers, personalDetailsProp), [policyMembers, personalDetailsProp]); useEffect(() => { - const newUsersToInviteDict = {}; - const newPersonalDetailsDict = {}; - const newSelectedOptionsDict = {}; + const newUsersToInviteDict: Record = {}; + const newPersonalDetailsDict: Record = {}; + const newSelectedOptionsDict: Record = {}; - const inviteOptions = OptionsListUtils.getMemberInviteOptions(props.personalDetails, props.betas, searchTerm, excludedUsers, true); + const inviteOptions = OptionsListUtils.getMemberInviteOptions(personalDetailsProp, betas ?? [], searchTerm, excludedUsers, true); // Update selectedOptions with the latest personalDetails and policyMembers information - const detailsMap = {}; - _.each(inviteOptions.personalDetails, (detail) => (detailsMap[detail.login] = OptionsListUtils.formatMemberForList(detail))); + const detailsMap: Record = {}; + inviteOptions.personalDetails.forEach((detail) => { + if (!detail.login) { + return; + } - const newSelectedOptions = []; - _.each(_.keys(props.invitedEmailsToAccountIDsDraft), (login) => { - if (!_.has(detailsMap, login)) { + detailsMap[detail.login] = OptionsListUtils.formatMemberForList(detail); + }); + + const newSelectedOptions: MemberForList[] = []; + Object.keys(invitedEmailsToAccountIDsDraft ?? {}).forEach((login) => { + if (!(login in detailsMap)) { return; } newSelectedOptions.push({...detailsMap[login], isSelected: true}); }); - _.each(selectedOptions, (option) => { - newSelectedOptions.push(_.has(detailsMap, option.login) ? {...detailsMap[option.login], isSelected: true} : option); + selectedOptions.forEach((option) => { + newSelectedOptions.push(option.login && option.login in detailsMap ? {...detailsMap[option.login], isSelected: true} : option); }); const userToInvite = inviteOptions.userToInvite; // Only add the user to the invites list if it is valid - if (userToInvite) { + if (typeof userToInvite?.accountID === 'number') { newUsersToInviteDict[userToInvite.accountID] = userToInvite; } // Add all personal details to the new dict - _.each(inviteOptions.personalDetails, (details) => { + inviteOptions.personalDetails.forEach((details) => { + if (typeof details.accountID !== 'number') { + return; + } newPersonalDetailsDict[details.accountID] = details; }); // Add all selected options to the new dict - _.each(newSelectedOptions, (option) => { + newSelectedOptions.forEach((option) => { + if (typeof option.accountID !== 'number') { + return; + } newSelectedOptionsDict[option.accountID] = option; }); // Strip out dictionary keys and update arrays - setUsersToInvite(_.values(newUsersToInviteDict)); - setPersonalDetails(_.values(newPersonalDetailsDict)); - setSelectedOptions(_.values(newSelectedOptionsDict)); + setUsersToInvite(Object.values(newUsersToInviteDict)); + setPersonalDetails(Object.values(newPersonalDetailsDict)); + setSelectedOptions(Object.values(newSelectedOptionsDict)); // eslint-disable-next-line react-hooks/exhaustive-deps -- we don't want to recalculate when selectedOptions change - }, [props.personalDetails, props.policyMembers, props.betas, searchTerm, excludedUsers]); + }, [personalDetailsProp, policyMembers, betas, searchTerm, excludedUsers]); - const sections = useMemo(() => { - const sectionsArr = []; + const sections: MembersSection[] = useMemo(() => { + const sectionsArr: MembersSection[] = []; let indexOffset = 0; if (!didScreenTransitionEnd) { @@ -171,13 +172,13 @@ function WorkspaceInvitePage(props) { // Filter all options that is a part of the search term or in the personal details let filterSelectedOptions = selectedOptions; if (searchTerm !== '') { - filterSelectedOptions = _.filter(selectedOptions, (option) => { - const accountID = lodashGet(option, 'accountID', null); - const isOptionInPersonalDetails = _.some(personalDetails, (personalDetail) => personalDetail.accountID === accountID); + filterSelectedOptions = selectedOptions.filter((option) => { + const accountID = option.accountID; + const isOptionInPersonalDetails = Object.values(personalDetails).some((personalDetail) => personalDetail.accountID === accountID); const parsedPhoneNumber = parsePhoneNumber(LoginUtils.appendCountryCode(Str.removeSMSDomain(searchTerm))); - const searchValue = parsedPhoneNumber.possible ? parsedPhoneNumber.number.e164 : searchTerm.toLowerCase(); + const searchValue = parsedPhoneNumber.possible ? parsedPhoneNumber.number?.e164 ?? '' : searchTerm.toLowerCase(); - const isPartOfSearchTerm = option.text.toLowerCase().includes(searchValue) || option.login.toLowerCase().includes(searchValue); + const isPartOfSearchTerm = !!option.text?.toLowerCase().includes(searchValue) || !!option.login?.toLowerCase().includes(searchValue); return isPartOfSearchTerm || isOptionInPersonalDetails; }); } @@ -191,20 +192,20 @@ function WorkspaceInvitePage(props) { indexOffset += filterSelectedOptions.length; // Filtering out selected users from the search results - const selectedLogins = _.map(selectedOptions, ({login}) => login); - const personalDetailsWithoutSelected = _.filter(personalDetails, ({login}) => !_.contains(selectedLogins, login)); - const personalDetailsFormatted = _.map(personalDetailsWithoutSelected, OptionsListUtils.formatMemberForList); + const selectedLogins = selectedOptions.map(({login}) => login); + const personalDetailsWithoutSelected = Object.values(personalDetails).filter(({login}) => !selectedLogins.some((selectedLogin) => selectedLogin === login)); + const personalDetailsFormatted = personalDetailsWithoutSelected.map((item) => OptionsListUtils.formatMemberForList(item)); sectionsArr.push({ title: translate('common.contacts'), data: personalDetailsFormatted, - shouldShow: !_.isEmpty(personalDetailsFormatted), + shouldShow: !isEmptyObject(personalDetailsFormatted), indexOffset, }); indexOffset += personalDetailsFormatted.length; - _.each(usersToInvite, (userToInvite) => { - const hasUnselectedUserToInvite = !_.contains(selectedLogins, userToInvite.login); + Object.values(usersToInvite).forEach((userToInvite) => { + const hasUnselectedUserToInvite = !selectedLogins.some((selectedLogin) => selectedLogin === userToInvite.login); if (hasUnselectedUserToInvite) { sectionsArr.push({ @@ -219,14 +220,14 @@ function WorkspaceInvitePage(props) { return sectionsArr; }, [personalDetails, searchTerm, selectedOptions, usersToInvite, translate, didScreenTransitionEnd]); - const toggleOption = (option) => { - Policy.clearErrors(props.route.params.policyID); + const toggleOption = (option: MemberForList) => { + Policy.clearErrors(route.params.policyID); - const isOptionInList = _.some(selectedOptions, (selectedOption) => selectedOption.login === option.login); + const isOptionInList = selectedOptions.some((selectedOption) => selectedOption.login === option.login); - let newSelectedOptions; + let newSelectedOptions: MemberForList[]; if (isOptionInList) { - newSelectedOptions = _.reject(selectedOptions, (selectedOption) => selectedOption.login === option.login); + newSelectedOptions = selectedOptions.filter((selectedOption) => selectedOption.login !== option.login); } else { newSelectedOptions = [...selectedOptions, {...option, isSelected: true}]; } @@ -234,14 +235,14 @@ function WorkspaceInvitePage(props) { setSelectedOptions(newSelectedOptions); }; - const validate = () => { - const errors = {}; + const validate = (): boolean => { + const errors: Errors = {}; if (selectedOptions.length <= 0) { - errors.noUserSelected = true; + errors.noUserSelected = 'true'; } - Policy.setWorkspaceErrors(props.route.params.policyID, errors); - return _.size(errors) <= 0; + Policy.setWorkspaceErrors(route.params.policyID, errors); + return isEmptyObject(errors); }; const inviteUser = () => { @@ -249,27 +250,24 @@ function WorkspaceInvitePage(props) { return; } - const invitedEmailsToAccountIDs = {}; - _.each(selectedOptions, (option) => { - const login = option.login || ''; - const accountID = lodashGet(option, 'accountID', ''); + const invitedEmailsToAccountIDs: InvitedEmailsToAccountIDs = {}; + selectedOptions.forEach((option) => { + const login = option.login ?? ''; + const accountID = option.accountID ?? ''; if (!login.toLowerCase().trim() || !accountID) { return; } invitedEmailsToAccountIDs[login] = Number(accountID); }); - Policy.setWorkspaceInviteMembersDraft(props.route.params.policyID, invitedEmailsToAccountIDs); - Navigation.navigate(ROUTES.WORKSPACE_INVITE_MESSAGE.getRoute(props.route.params.policyID)); + Policy.setWorkspaceInviteMembersDraft(route.params.policyID, invitedEmailsToAccountIDs); + Navigation.navigate(ROUTES.WORKSPACE_INVITE_MESSAGE.getRoute(route.params.policyID)); }; - const [policyName, shouldShowAlertPrompt] = useMemo( - () => [lodashGet(props.policy, 'name'), _.size(lodashGet(props.policy, 'errors', {})) > 0 || lodashGet(props.policy, 'alertMessage', '').length > 0], - [props.policy], - ); + const [policyName, shouldShowAlertPrompt] = useMemo(() => [policy?.name ?? '', !isEmptyObject(policy?.errors) || !!policy?.alertMessage], [policy]); const headerMessage = useMemo(() => { const searchValue = searchTerm.trim().toLowerCase(); - if (usersToInvite.length === 0 && CONST.EXPENSIFY_EMAILS.includes(searchValue)) { + if (usersToInvite.length === 0 && CONST.EXPENSIFY_EMAILS.some((email) => email === searchValue)) { return translate('messages.errorMessageInvalidEmail'); } if ( @@ -289,8 +287,8 @@ function WorkspaceInvitePage(props) { testID={WorkspaceInvitePage.displayName} > Navigation.goBack(ROUTES.SETTINGS_WORKSPACES)} > { - Policy.clearErrors(props.route.params.policyID); + Policy.clearErrors(route.params.policyID); Navigation.goBack(); }} /> @@ -316,7 +314,7 @@ function WorkspaceInvitePage(props) { onSelectRow={toggleOption} onConfirm={inviteUser} showScrollIndicator - showLoadingPlaceholder={!didScreenTransitionEnd || !OptionsListUtils.isPersonalDetailsReady(props.personalDetails)} + showLoadingPlaceholder={!didScreenTransitionEnd || !OptionsListUtils.isPersonalDetailsReady(personalDetailsProp)} shouldPreventDefaultFocusOnSelectRow={!DeviceCapabilities.canUseTouchScreen()} /> @@ -325,7 +323,7 @@ function WorkspaceInvitePage(props) { isAlertVisible={shouldShowAlertPrompt} buttonText={translate('common.next')} onSubmit={inviteUser} - message={[props.policy.alertMessage, {isTranslated: true}]} + message={[policy?.alertMessage ?? '', {isTranslated: true}]} containerStyles={[styles.flexReset, styles.flexGrow0, styles.flexShrink0, styles.flexBasisAuto, styles.mb5]} enabledWhenOffline disablePressOnEnter @@ -336,24 +334,18 @@ function WorkspaceInvitePage(props) { ); } -WorkspaceInvitePage.propTypes = propTypes; -WorkspaceInvitePage.defaultProps = defaultProps; WorkspaceInvitePage.displayName = 'WorkspaceInvitePage'; -export default compose( - withPolicyAndFullscreenLoading, - withOnyx({ +export default withPolicyAndFullscreenLoading( + withOnyx({ personalDetails: { key: ONYXKEYS.PERSONAL_DETAILS_LIST, }, betas: { key: ONYXKEYS.BETAS, }, - isLoadingReportData: { - key: ONYXKEYS.IS_LOADING_REPORT_DATA, - }, invitedEmailsToAccountIDsDraft: { key: ({route}) => `${ONYXKEYS.COLLECTION.WORKSPACE_INVITE_MEMBERS_DRAFT}${route.params.policyID.toString()}`, }, - }), -)(WorkspaceInvitePage); + })(WorkspaceInvitePage), +); diff --git a/src/pages/workspace/WorkspaceNamePage.tsx b/src/pages/workspace/WorkspaceNamePage.tsx index e9d1ddd021d0..59679456be56 100644 --- a/src/pages/workspace/WorkspaceNamePage.tsx +++ b/src/pages/workspace/WorkspaceNamePage.tsx @@ -8,6 +8,7 @@ import ScreenWrapper from '@components/ScreenWrapper'; import TextInput from '@components/TextInput'; import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; +import * as ErrorUtils from '@libs/ErrorUtils'; import Navigation from '@libs/Navigation/Navigation'; import * as ValidationUtils from '@libs/ValidationUtils'; import * as Policy from '@userActions/Policy'; @@ -41,10 +42,10 @@ function WorkspaceNamePage({policy}: Props) { if (!ValidationUtils.isRequiredFulfilled(name)) { errors.name = 'workspace.editor.nameIsRequiredError'; - } else if ([...name].length > CONST.WORKSPACE_NAME_CHARACTER_LIMIT) { + } else if ([...name].length > CONST.TITLE_CHARACTER_LIMIT) { // Uses the spread syntax to count the number of Unicode code points instead of the number of UTF-16 // code units. - errors.name = 'workspace.editor.nameIsTooLongError'; + ErrorUtils.addErrorMessage(errors, 'name', ['common.error.characterLimitExceedCounter', {length: [...name].length, limit: CONST.TITLE_CHARACTER_LIMIT}]); } return errors; diff --git a/src/pages/workspace/WorkspaceNewRoomPage.js b/src/pages/workspace/WorkspaceNewRoomPage.js index b8676faf0510..5c77f3a03191 100644 --- a/src/pages/workspace/WorkspaceNewRoomPage.js +++ b/src/pages/workspace/WorkspaceNewRoomPage.js @@ -211,6 +211,8 @@ function WorkspaceNewRoomPage(props) { } else if (ValidationUtils.isExistingRoomName(values.roomName, props.reports, values.policyID)) { // Certain names are reserved for default rooms and should not be used for policy rooms. ErrorUtils.addErrorMessage(errors, 'roomName', 'newRoomPage.roomAlreadyExistsError'); + } else if (values.roomName.length > CONST.TITLE_CHARACTER_LIMIT) { + ErrorUtils.addErrorMessage(errors, 'roomName', ['common.error.characterLimitExceedCounter', {length: values.roomName.length, limit: CONST.TITLE_CHARACTER_LIMIT}]); } if (!values.policyID) { diff --git a/src/pages/workspace/WorkspaceOverviewCurrencyPage.js b/src/pages/workspace/WorkspaceProfileCurrencyPage.js similarity index 100% rename from src/pages/workspace/WorkspaceOverviewCurrencyPage.js rename to src/pages/workspace/WorkspaceProfileCurrencyPage.js diff --git a/src/pages/workspace/WorkspaceOverviewPage.js b/src/pages/workspace/WorkspaceProfilePage.js similarity index 93% rename from src/pages/workspace/WorkspaceOverviewPage.js rename to src/pages/workspace/WorkspaceProfilePage.js index 4e18d09c9137..c030e7e3841a 100644 --- a/src/pages/workspace/WorkspaceOverviewPage.js +++ b/src/pages/workspace/WorkspaceProfilePage.js @@ -50,23 +50,23 @@ const defaultProps = { ...policyDefaultProps, }; -function WorkspaceOverviewPage({policy, currencyList, route}) { +function WorkspaceProfilePage({policy, currencyList, route}) { const styles = useThemeStyles(); const {translate} = useLocalize(); const formattedCurrency = !_.isEmpty(policy) && !_.isEmpty(currencyList) ? `${policy.outputCurrency} - ${currencyList[policy.outputCurrency].symbol}` : ''; - const onPressCurrency = useCallback(() => Navigation.navigate(ROUTES.WORKSPACE_OVERVIEW_CURRENCY.getRoute(policy.id)), [policy.id]); - const onPressName = useCallback(() => Navigation.navigate(ROUTES.WORKSPACE_OVERVIEW_NAME.getRoute(policy.id)), [policy.id]); + const onPressCurrency = useCallback(() => Navigation.navigate(ROUTES.WORKSPACE_PROFILE_CURRENCY.getRoute(policy.id)), [policy.id]); + const onPressName = useCallback(() => Navigation.navigate(ROUTES.WORKSPACE_PROFILE_NAME.getRoute(policy.id)), [policy.id]); const policyName = lodashGet(policy, 'name', ''); const readOnly = !PolicyUtils.isPolicyAdmin(policy); return ( alignItems: 'center', }, + qrShareSection: { + width: 264, + }, + sectionMenuItemTopDescription: { ...spacing.ph8, ...spacing.mhn8, diff --git a/src/types/onyx/InvitedEmailsToAccountIDs.ts b/src/types/onyx/InvitedEmailsToAccountIDs.ts new file mode 100644 index 000000000000..929d21746682 --- /dev/null +++ b/src/types/onyx/InvitedEmailsToAccountIDs.ts @@ -0,0 +1,3 @@ +type InvitedEmailsToAccountIDs = Record; + +export default InvitedEmailsToAccountIDs; diff --git a/src/types/onyx/Policy.ts b/src/types/onyx/Policy.ts index 989bab441dda..d5ed5dd36aba 100644 --- a/src/types/onyx/Policy.ts +++ b/src/types/onyx/Policy.ts @@ -31,6 +31,18 @@ type DisabledFields = { reimbursable?: boolean; }; +// These types are for the Integration connections for a policy (eg. Quickbooks, Xero, etc). +// This data is not yet used in the codebase which is why it is given a very generic type, but the data is being put into Onyx for future use. +// Once the data is being used, these types should be defined appropriately. +type ConnectionLastSync = Record; +type ConnectionData = Record; +type ConnectionConfig = Record; +type Connection = { + lastSync?: ConnectionLastSync; + data: ConnectionData; + config: ConnectionConfig; +}; + type AutoReportingOffset = number | ValueOf; type Policy = { @@ -158,6 +170,9 @@ type Policy = { /** ReportID of the announce room for this workspace */ chatReportIDAnnounce?: number; + + /** All the integration connections attached to the policy */ + connections?: Record; }; export default Policy; diff --git a/src/types/onyx/index.ts b/src/types/onyx/index.ts index cdc87f5df252..aae3b6f2532a 100644 --- a/src/types/onyx/index.ts +++ b/src/types/onyx/index.ts @@ -29,6 +29,7 @@ import type FrequentlyUsedEmoji from './FrequentlyUsedEmoji'; import type {FundList} from './Fund'; import type Fund from './Fund'; import type IntroSelected from './IntroSelected'; +import type InvitedEmailsToAccountIDs from './InvitedEmailsToAccountIDs'; import type IOU from './IOU'; import type LastPaymentMethod from './LastPaymentMethod'; import type Locale from './Locale'; @@ -171,6 +172,7 @@ export type { NewRoomForm, IKnowATeacherForm, IntroSchoolPrincipalForm, + InvitedEmailsToAccountIDs, PrivateNotesForm, ReportFieldEditForm, RoomNameForm, diff --git a/tests/unit/CardUtilsTest.js b/tests/unit/CardUtilsTest.ts similarity index 97% rename from tests/unit/CardUtilsTest.js rename to tests/unit/CardUtilsTest.ts index f754b0895c6d..405b588fa93f 100644 --- a/tests/unit/CardUtilsTest.js +++ b/tests/unit/CardUtilsTest.ts @@ -1,4 +1,4 @@ -const cardUtils = require('../../src/libs/CardUtils'); +import * as cardUtils from '@src/libs/CardUtils'; const shortDate = '0924'; const shortDateSlashed = '09/24';