diff --git a/.github/actions/composite/setupNode/action.yml b/.github/actions/composite/setupNode/action.yml index 0b32d8ee6dc1..c6a6029e06e0 100644 --- a/.github/actions/composite/setupNode/action.yml +++ b/.github/actions/composite/setupNode/action.yml @@ -18,13 +18,13 @@ runs: desktop/package-lock.json - id: cache-node-modules - uses: actions/cache@v3 + uses: actions/cache@v4 with: path: node_modules key: ${{ runner.os }}-node-modules-${{ hashFiles('package-lock.json', 'patches/**') }} - id: cache-desktop-node-modules - uses: actions/cache@v3 + uses: actions/cache@v4 with: path: desktop/node_modules key: ${{ runner.os }}-desktop-node-modules-${{ hashFiles('desktop/package-lock.json', 'desktop/patches/**') }} diff --git a/.github/workflows/checkE2ETestCode.yml b/.github/workflows/checkE2ETestCode.yml new file mode 100644 index 000000000000..090b7a7f23e4 --- /dev/null +++ b/.github/workflows/checkE2ETestCode.yml @@ -0,0 +1,23 @@ +name: Check e2e test code builds correctly + +on: + workflow_call: + pull_request: + types: [opened, synchronize] + paths: + - 'tests/e2e/**' + - 'src/libs/E2E/**' + +jobs: + lint: + if: ${{ github.actor != 'OSBotify' || github.event_name == 'workflow_call' }} + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node + uses: ./.github/actions/composite/setupNode + + - name: Verify e2e tests compile correctly + run: npm run e2e-test-runner-build \ No newline at end of file diff --git a/.github/workflows/e2ePerformanceTests.yml b/.github/workflows/e2ePerformanceTests.yml index 338cb8313465..8a47ea4bb220 100644 --- a/.github/workflows/e2ePerformanceTests.yml +++ b/.github/workflows/e2ePerformanceTests.yml @@ -15,6 +15,10 @@ on: type: string required: true +concurrency: + group: "${{ github.ref }}-e2e" + cancel-in-progress: true + jobs: buildBaseline: runs-on: ubuntu-latest-xl @@ -175,23 +179,11 @@ jobs: - name: Rename delta APK run: mv "${{steps.downloadDeltaAPK.outputs.download-path}}/app-e2edelta-release.apk" "${{steps.downloadDeltaAPK.outputs.download-path}}/app-e2edeltaRelease.apk" - - name: Copy e2e code into zip folder - run: cp -r tests/e2e zip + - name: Compile test runner to be executable in a nodeJS environment + run: npm run e2e-test-runner-build - # Note: we can't reuse the apps tsconfig, as it depends on modules that aren't available in the AWS Device Farm environment - - name: Write tsconfig.json to zip folder - run: | - echo '{ - "compilerOptions": { - "target": "ESNext", - "module": "commonjs", - "strict": true, - "esModuleInterop": true, - "skipLibCheck": true, - "forceConsistentCasingInFileNames": true, - "allowJs": true, - } - }' > zip/tsconfig.json + - name: Copy e2e code into zip folder + run: cp tests/e2e/dist/index.js zip/testRunner.js - name: Zip everything in the zip directory up run: zip -qr App.zip ./zip diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 33c850823413..50e886942c98 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -7,8 +7,13 @@ on: branches-ignore: [staging, production] paths: ['**.js', '**.ts', '**.tsx', '**.json', '**.mjs', '**.cjs', 'config/.editorconfig', '.watchmanconfig', '.imgbotconfig'] +concurrency: + group: "${{ github.ref }}-lint" + cancel-in-progress: true + jobs: lint: + name: Run ESLint if: ${{ github.actor != 'OSBotify' || github.event_name == 'workflow_call' }} runs-on: ubuntu-latest steps: diff --git a/.github/workflows/platformDeploy.yml b/.github/workflows/platformDeploy.yml index 818441828bf0..4d6597334447 100644 --- a/.github/workflows/platformDeploy.yml +++ b/.github/workflows/platformDeploy.yml @@ -194,7 +194,7 @@ jobs: bundler-cache: true - name: Cache Pod dependencies - uses: actions/cache@v3 + uses: actions/cache@v4 id: pods-cache with: path: ios/Pods diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index bdc14950a337..71b4bc3d8fc3 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -7,6 +7,10 @@ on: branches-ignore: [staging, production] paths: ['**.js', '**.ts', '**.tsx', '**.sh', 'package.json', 'package-lock.json'] +concurrency: + group: "${{ github.ref }}-jest" + cancel-in-progress: true + jobs: jest: if: ${{ github.actor != 'OSBotify' && github.actor != 'imgbot[bot]' || github.event_name == 'workflow_call' }} @@ -31,7 +35,7 @@ jobs: - name: Cache Jest cache id: cache-jest-cache - uses: actions/cache@ac25611caef967612169ab7e95533cf932c32270 + uses: actions/cache@v4 with: path: .jest-cache key: ${{ runner.os }}-jest diff --git a/.github/workflows/testBuild.yml b/.github/workflows/testBuild.yml index 9548c3a6e595..3f02430f3c1f 100644 --- a/.github/workflows/testBuild.yml +++ b/.github/workflows/testBuild.yml @@ -167,7 +167,7 @@ jobs: bundler-cache: true - name: Cache Pod dependencies - uses: actions/cache@v3 + uses: actions/cache@v4 id: pods-cache with: path: ios/Pods diff --git a/android/app/build.gradle b/android/app/build.gradle index 9886cd5cccec..e285d0bff26f 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 1001044600 - versionName "1.4.46-0" + versionCode 1001044601 + versionName "1.4.46-1" } flavorDimensions "default" diff --git a/ios/NewExpensify/Info.plist b/ios/NewExpensify/Info.plist index dff05f61933e..374594698200 100644 --- a/ios/NewExpensify/Info.plist +++ b/ios/NewExpensify/Info.plist @@ -40,7 +40,7 @@ CFBundleVersion - 1.4.46.0 + 1.4.46.1 ITSAppUsesNonExemptEncryption LSApplicationQueriesSchemes diff --git a/ios/NewExpensifyTests/Info.plist b/ios/NewExpensifyTests/Info.plist index fa6995f65b5a..e314ea1595e1 100644 --- a/ios/NewExpensifyTests/Info.plist +++ b/ios/NewExpensifyTests/Info.plist @@ -19,6 +19,6 @@ CFBundleSignature ???? CFBundleVersion - 1.4.46.0 + 1.4.46.1 diff --git a/ios/NotificationServiceExtension/Info.plist b/ios/NotificationServiceExtension/Info.plist index e8cd0ebb4e0a..aaec6344175f 100644 --- a/ios/NotificationServiceExtension/Info.plist +++ b/ios/NotificationServiceExtension/Info.plist @@ -13,7 +13,7 @@ CFBundleShortVersionString 1.4.46 CFBundleVersion - 1.4.46.0 + 1.4.46.1 NSExtension NSExtensionPointIdentifier diff --git a/jest.config.js b/jest.config.js index 441507af4228..5b36e44c7581 100644 --- a/jest.config.js +++ b/jest.config.js @@ -23,7 +23,7 @@ module.exports = { }, testEnvironment: 'jsdom', setupFiles: ['/jest/setup.ts', './node_modules/@react-native-google-signin/google-signin/jest/build/setup.js'], - setupFilesAfterEnv: ['@testing-library/jest-native/extend-expect', '/jest/setupAfterEnv.ts', '/tests/perf-test/setupAfterEnv.js'], + setupFilesAfterEnv: ['/jest/setupAfterEnv.ts', '/tests/perf-test/setupAfterEnv.js'], cacheDirectory: '/.jest-cache', moduleNameMapper: { '\\.(lottie)$': '/__mocks__/fileMock.ts', diff --git a/jest/setupAfterEnv.ts b/jest/setupAfterEnv.ts index 6f7836b64dbb..d59495874588 100644 --- a/jest/setupAfterEnv.ts +++ b/jest/setupAfterEnv.ts @@ -1 +1,4 @@ +// This is required in order for jest to recognize custom matchers like toBeDisabled. This can be removed once testing-library/react-native version is bumped to v12.4 or later +import '@testing-library/jest-native/extend-expect'; + jest.useRealTimers(); diff --git a/package-lock.json b/package-lock.json index cafbead8f5eb..80d3d1c6e911 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "new.expensify", - "version": "1.4.46-0", + "version": "1.4.46-1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "new.expensify", - "version": "1.4.46-0", + "version": "1.4.46-1", "hasInstallScript": true, "license": "MIT", "dependencies": { @@ -202,7 +202,7 @@ "css-loader": "^6.7.2", "diff-so-fancy": "^1.3.0", "dotenv": "^16.0.3", - "electron": "^26.6.8", + "electron": "^29.0.0", "electron-builder": "24.6.4", "eslint": "^7.6.0", "eslint-config-airbnb-typescript": "^17.1.0", @@ -24894,20 +24894,22 @@ } }, "node_modules/browserify-sign": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/browserify-sign/-/browserify-sign-4.2.1.tgz", - "integrity": "sha512-/vrA5fguVAKKAVTNJjgSm1tRQDHUU6DbwO9IROu/0WAzC8PKhucDSh18J0RMvVeHAn5puMd+QHC2erPRNf8lmg==", - "license": "ISC", + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/browserify-sign/-/browserify-sign-4.2.2.tgz", + "integrity": "sha512-1rudGyeYY42Dk6texmv7c4VcQ0EsvVbLwZkA+AQB7SxvXxmcD93jcHie8bzecJ+ChDlmAm2Qyu0+Ccg5uhZXCg==", "dependencies": { - "bn.js": "^5.1.1", - "browserify-rsa": "^4.0.1", + "bn.js": "^5.2.1", + "browserify-rsa": "^4.1.0", "create-hash": "^1.2.0", "create-hmac": "^1.1.7", - "elliptic": "^6.5.3", + "elliptic": "^6.5.4", "inherits": "^2.0.4", - "parse-asn1": "^5.1.5", - "readable-stream": "^3.6.0", - "safe-buffer": "^5.2.0" + "parse-asn1": "^5.1.6", + "readable-stream": "^3.6.2", + "safe-buffer": "^5.2.1" + }, + "engines": { + "node": ">= 4" } }, "node_modules/browserify-sign/node_modules/readable-stream": { @@ -28644,14 +28646,14 @@ } }, "node_modules/electron": { - "version": "26.6.8", - "resolved": "https://registry.npmjs.org/electron/-/electron-26.6.8.tgz", - "integrity": "sha512-nuzJ5nVButL1jErc97IVb+A6jbContMg5Uuz5fhmZ4NLcygLkSW8FZpnOT7A4k8Saa95xDJOvqGZyQdI/OPNFw==", + "version": "29.0.0", + "resolved": "https://registry.npmjs.org/electron/-/electron-29.0.0.tgz", + "integrity": "sha512-HhrRC5vWb6fAbWXP3A6ABwKUO9JvYSC4E141RzWFgnDBqNiNtabfmgC8hsVeCR65RQA2MLSDgC8uP52I9zFllQ==", "dev": true, "hasInstallScript": true, "dependencies": { "@electron/get": "^2.0.0", - "@types/node": "^18.11.18", + "@types/node": "^20.9.0", "extract-zip": "^2.0.1" }, "bin": { @@ -28932,15 +28934,6 @@ "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.435.tgz", "integrity": "sha512-B0CBWVFhvoQCW/XtjRzgrmqcgVWg6RXOEM/dK59+wFV93BFGR6AeNKc4OyhM+T3IhJaOOG8o/V+33Y2mwJWtzw==" }, - "node_modules/electron/node_modules/@types/node": { - "version": "18.19.8", - "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.8.tgz", - "integrity": "sha512-g1pZtPhsvGVTwmeVoexWZLTQaOvXwoSq//pTL0DHeNzUDrFnir4fgETdhjhIxjVnN+hKOuh98+E1eMLnUXstFg==", - "dev": true, - "dependencies": { - "undici-types": "~5.26.4" - } - }, "node_modules/element-resize-detector": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/element-resize-detector/-/element-resize-detector-1.2.4.tgz", diff --git a/package.json b/package.json index 46ff187bf64c..c927c41134db 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "new.expensify", - "version": "1.4.46-0", + "version": "1.4.46-1", "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.", @@ -55,7 +55,8 @@ "gh-actions-unused-styles": "./.github/scripts/findUnusedKeys.sh", "workflow-test": "./workflow_tests/scripts/runWorkflowTests.sh", "workflow-test:generate": "ts-node workflow_tests/utils/preGenerateTest.js", - "setup-https": "mkcert -install && mkcert -cert-file config/webpack/certificate.pem -key-file config/webpack/key.pem dev.new.expensify.com localhost 127.0.0.1" + "setup-https": "mkcert -install && mkcert -cert-file config/webpack/certificate.pem -key-file config/webpack/key.pem dev.new.expensify.com localhost 127.0.0.1", + "e2e-test-runner-build": "ncc build tests/e2e/testRunner.js -o tests/e2e/dist/" }, "dependencies": { "@dotlottie/react-player": "^1.6.3", @@ -250,7 +251,7 @@ "css-loader": "^6.7.2", "diff-so-fancy": "^1.3.0", "dotenv": "^16.0.3", - "electron": "^26.6.8", + "electron": "^29.0.0", "electron-builder": "24.6.4", "eslint": "^7.6.0", "eslint-config-airbnb-typescript": "^17.1.0", diff --git a/src/CONST.ts b/src/CONST.ts index 6626b798d314..9ed2903941b6 100755 --- a/src/CONST.ts +++ b/src/CONST.ts @@ -691,6 +691,7 @@ const CONST = { DOMAIN_ALL: 'domainAll', POLICY_ROOM: 'policyRoom', POLICY_EXPENSE_CHAT: 'policyExpenseChat', + SELF_DM: 'selfDM', }, WORKSPACE_CHAT_ROOMS: { ANNOUNCE: '#announce', @@ -3338,6 +3339,10 @@ const CONST = { SESSION_STORAGE_KEYS: { INITIAL_URL: 'INITIAL_URL', }, + + AUTH_TOKEN_TYPE: { + ANONYMOUS: 'anonymousAccount', + }, } as const; type Country = keyof typeof CONST.ALL_COUNTRIES; diff --git a/src/ROUTES.ts b/src/ROUTES.ts index fb99108c7e97..e9cdce4f6ed9 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -486,6 +486,10 @@ const ROUTES = { route: 'workspace/:policyID/workflows', getRoute: (policyID: string) => `workspace/${policyID}/workflows` as const, }, + WORKSPACE_WORKFLOWS_APPROVER: { + route: 'workspace/:policyID/settings/workflows/approver', + getRoute: (policyId: string) => `workspace/${policyId}/settings/workflows/approver` as const, + }, WORKSPACE_WORKFLOWS_AUTOREPORTING_FREQUENCY: { route: 'workspace/:policyID/settings/workflows/auto-reporting-frequency', getRoute: (policyID: string) => `workspace/${policyID}/settings/workflows/auto-reporting-frequency` as const, diff --git a/src/SCREENS.ts b/src/SCREENS.ts index cc7df01524f7..ff3dbfd7f901 100644 --- a/src/SCREENS.ts +++ b/src/SCREENS.ts @@ -216,6 +216,7 @@ const SCREENS = { CATEGORIES: 'Workspace_Categories', CURRENCY: 'Workspace_Profile_Currency', WORKFLOWS: 'Workspace_Workflows', + WORKFLOWS_APPROVER: 'Workspace_Workflows_Approver', WORKFLOWS_AUTO_REPORTING_FREQUENCY: 'Workspace_Workflows_Auto_Reporting_Frequency', WORKFLOWS_AUTO_REPORTING_MONTHLY_OFFSET: 'Workspace_Workflows_Auto_Reporting_Monthly_Offset', DESCRIPTION: 'Workspace_Profile_Description', diff --git a/src/components/FocusModeNotification.js b/src/components/FocusModeNotification.tsx similarity index 98% rename from src/components/FocusModeNotification.js rename to src/components/FocusModeNotification.tsx index 9ec16beead15..7b3f567d256b 100644 --- a/src/components/FocusModeNotification.js +++ b/src/components/FocusModeNotification.tsx @@ -28,7 +28,6 @@ function FocusModeNotification() { {translate('focusModeUpdateModal.prompt')} { User.clearFocusModeNotification(); diff --git a/src/components/MoneyRequestConfirmationList.js b/src/components/MoneyRequestConfirmationList.js index df2781d3ea89..dfe1d96b0e5d 100755 --- a/src/components/MoneyRequestConfirmationList.js +++ b/src/components/MoneyRequestConfirmationList.js @@ -282,7 +282,7 @@ function MoneyRequestConfirmationList(props) { }, [props.isEditingSplitBill, props.hasSmartScanFailed, transaction, didConfirmSplit]); const isMerchantEmpty = !props.iouMerchant || props.iouMerchant === CONST.TRANSACTION.PARTIAL_TRANSACTION_MERCHANT; - const shouldDisplayMerchantError = props.isPolicyExpenseChat && !props.isScanRequest && isMerchantEmpty; + const shouldDisplayMerchantError = props.isPolicyExpenseChat && shouldDisplayFieldError && isMerchantEmpty; useEffect(() => { if (shouldDisplayFieldError && didConfirmSplit) { @@ -750,14 +750,8 @@ function MoneyRequestConfirmationList(props) { }} disabled={didConfirm} interactive={!props.isReadOnly} - brickRoadIndicator={ - props.isPolicyExpenseChat && shouldDisplayFieldError && TransactionUtils.isMerchantMissing(transaction) ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : '' - } - error={ - shouldDisplayMerchantError || (props.isPolicyExpenseChat && shouldDisplayFieldError && TransactionUtils.isMerchantMissing(transaction)) - ? translate('common.error.enterMerchant') - : '' - } + brickRoadIndicator={shouldDisplayMerchantError ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : ''} + error={shouldDisplayMerchantError ? translate('common.error.enterMerchant') : ''} /> )} {shouldShowCategories && ( diff --git a/src/components/OptionsSelector/BaseOptionsSelector.js b/src/components/OptionsSelector/BaseOptionsSelector.js index c5c7c3ec50b0..c0258f1252ef 100755 --- a/src/components/OptionsSelector/BaseOptionsSelector.js +++ b/src/components/OptionsSelector/BaseOptionsSelector.js @@ -125,8 +125,11 @@ class BaseOptionsSelector extends Component { // Unregister the shortcut before registering a new one to avoid lingering shortcut listener this.unSubscribeFromKeyboardShortcut(); if (this.props.isFocused) { + this.subscribeActiveElement(); this.subscribeToEnterShortcut(); this.subscribeToCtrlEnterShortcut(); + } else { + this.unSubscribeActiveElement(); } } diff --git a/src/components/OptionsSelector/index.android.js b/src/components/OptionsSelector/index.android.js index ace5a5614ffb..1ed2d56e8742 100644 --- a/src/components/OptionsSelector/index.android.js +++ b/src/components/OptionsSelector/index.android.js @@ -1,6 +1,9 @@ import React, {forwardRef} from 'react'; import BaseOptionsSelector from './BaseOptionsSelector'; +/** + * @deprecated Please use `SelectionList` instead. + */ const OptionsSelector = forwardRef((props, ref) => ( ( 1; const displayNamesWithTooltips = ReportUtils.getDisplayNamesWithTooltips(OptionsListUtils.getPersonalDetailsForAccountIDs(participantAccountIDs, personalDetails), isMultipleParticipant); @@ -44,6 +45,7 @@ function ReportWelcomeText({report, policy, personalDetails}: ReportWelcomeTextP const moneyRequestOptions = ReportUtils.getMoneyRequestOptions(report, policy, participantAccountIDs); const additionalText = moneyRequestOptions.map((item) => translate(`reportActionsView.iouTypes.${item}`)).join(', '); const canEditPolicyDescription = ReportUtils.canEditPolicyDescription(policy); + const reportName = ReportUtils.getReportName(report); const navigateToReport = () => { if (!report?.reportID) { @@ -53,12 +55,22 @@ function ReportWelcomeText({report, policy, personalDetails}: ReportWelcomeTextP Navigation.navigate(ROUTES.REPORT_WITH_ID_DETAILS.getRoute(report.reportID)); }; + const welcomeHeroText = useMemo(() => { + if (isChatRoom) { + return translate('reportActionsView.welcomeToRoom', {roomName: reportName}); + } + + if (isSelfDM) { + return translate('reportActionsView.yourSpace'); + } + + return translate('reportActionsView.sayHello'); + }, [isChatRoom, isSelfDM, translate, reportName]); + return ( <> - - {isChatRoom ? translate('reportActionsView.welcomeToRoom', {roomName: ReportUtils.getReportName(report)}) : translate('reportActionsView.sayHello')} - + {welcomeHeroText} {isPolicyExpenseChat && @@ -114,6 +126,11 @@ function ReportWelcomeText({report, policy, personalDetails}: ReportWelcomeTextP {roomWelcomeMessage.phrase2 !== undefined && {roomWelcomeMessage.phrase2}} ))} + {isSelfDM && ( + + {translate('reportActionsView.beginningOfChatHistorySelfDM')} + + )} {isDefault && ( {translate('reportActionsView.beginningOfChatHistory')} diff --git a/src/components/SAMLLoadingIndicator.js b/src/components/SAMLLoadingIndicator.tsx similarity index 79% rename from src/components/SAMLLoadingIndicator.js rename to src/components/SAMLLoadingIndicator.tsx index 84f9098e564f..2be7b76e6cae 100644 --- a/src/components/SAMLLoadingIndicator.js +++ b/src/components/SAMLLoadingIndicator.tsx @@ -3,6 +3,7 @@ import {StyleSheet, View} from 'react-native'; import useLocalize from '@hooks/useLocalize'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; +import variables from '@styles/variables'; import Icon from './Icon'; import * as Expensicons from './Icon/Expensicons'; import * as Illustrations from './Icon/Illustrations'; @@ -23,8 +24,13 @@ function SAMLLoadingIndicator() { /> {translate('samlSignIn.launching')} - - {translate('samlSignIn.oneMoment')} + + + {translate('samlSignIn.oneMoment')} + diff --git a/src/components/SelectionList/UserListItem.tsx b/src/components/SelectionList/UserListItem.tsx index 0cfe4c1a509a..4619a2e54f74 100644 --- a/src/components/SelectionList/UserListItem.tsx +++ b/src/components/SelectionList/UserListItem.tsx @@ -85,7 +85,7 @@ function UserListItem({ style={[ styles.optionDisplayName, isFocused ? styles.sidebarLinkActiveText : styles.sidebarLinkText, - styles.sidebarLinkTextBold, + item.isBold !== false && styles.sidebarLinkTextBold, styles.pre, item.alternateText ? styles.mb1 : null, ]} diff --git a/src/components/SelectionList/types.ts b/src/components/SelectionList/types.ts index eaaed4e572cb..005a8ab21cc1 100644 --- a/src/components/SelectionList/types.ts +++ b/src/components/SelectionList/types.ts @@ -61,6 +61,9 @@ type ListItem = { /** Whether this option is disabled for selection */ isDisabled?: boolean; + /** List title is bold by default. Use this props to customize it */ + isBold?: boolean; + /** User accountID */ accountID?: number | null; diff --git a/src/components/ShowMoreButton/index.js b/src/components/ShowMoreButton.tsx similarity index 74% rename from src/components/ShowMoreButton/index.js rename to src/components/ShowMoreButton.tsx index 28c33d185cff..3411066a5376 100644 --- a/src/components/ShowMoreButton/index.js +++ b/src/components/ShowMoreButton.tsx @@ -1,42 +1,34 @@ -import PropTypes from 'prop-types'; import React from 'react'; import {View} from 'react-native'; -import _ from 'underscore'; -import Button from '@components/Button'; -import * as Expensicons from '@components/Icon/Expensicons'; -import Text from '@components/Text'; +import type {StyleProp, ViewStyle} from 'react-native'; import useLocalize from '@hooks/useLocalize'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import * as NumberFormatUtils from '@libs/NumberFormatUtils'; -import stylePropTypes from '@styles/stylePropTypes'; +import Button from './Button'; +import * as Expensicons from './Icon/Expensicons'; +import Text from './Text'; -const propTypes = { +type ShowMoreButtonProps = { /** Additional styles for container */ - containerStyle: stylePropTypes, + containerStyle?: StyleProp; /** The number of currently shown items */ - currentCount: PropTypes.number, + currentCount?: number; /** The total number of items that could be shown */ - totalCount: PropTypes.number, + totalCount?: number; /** A handler that fires when button has been pressed */ - onPress: PropTypes.func.isRequired, + onPress: () => void; }; -const defaultProps = { - containerStyle: {}, - currentCount: undefined, - totalCount: undefined, -}; - -function ShowMoreButton({containerStyle, currentCount, totalCount, onPress}) { +function ShowMoreButton({containerStyle, currentCount, totalCount, onPress}: ShowMoreButtonProps) { const {translate, preferredLocale} = useLocalize(); const theme = useTheme(); const styles = useThemeStyles(); - const shouldShowCounter = _.isNumber(currentCount) && _.isNumber(totalCount); + const shouldShowCounter = !!(currentCount && totalCount); return ( @@ -67,7 +59,5 @@ function ShowMoreButton({containerStyle, currentCount, totalCount, onPress}) { } ShowMoreButton.displayName = 'ShowMoreButton'; -ShowMoreButton.propTypes = propTypes; -ShowMoreButton.defaultProps = defaultProps; export default ShowMoreButton; diff --git a/src/components/TaxPicker/index.js b/src/components/TaxPicker.tsx similarity index 68% rename from src/components/TaxPicker/index.js rename to src/components/TaxPicker.tsx index be15cd546b36..664aa741c400 100644 --- a/src/components/TaxPicker/index.js +++ b/src/components/TaxPicker.tsx @@ -1,16 +1,32 @@ -import lodashGet from 'lodash/get'; import React, {useMemo, useState} from 'react'; -import _ from 'underscore'; -import OptionsSelector from '@components/OptionsSelector'; +import type {EdgeInsets} from 'react-native-safe-area-context'; import useLocalize from '@hooks/useLocalize'; import useStyleUtils from '@hooks/useStyleUtils'; import useThemeStyles from '@hooks/useThemeStyles'; import * as OptionsListUtils from '@libs/OptionsListUtils'; import * as TransactionUtils from '@libs/TransactionUtils'; import CONST from '@src/CONST'; -import {defaultProps, propTypes} from './taxPickerPropTypes'; +import type {TaxRatesWithDefault} from '@src/types/onyx'; +import OptionsSelector from './OptionsSelector'; -function TaxPicker({selectedTaxRate, taxRates, insets, onSubmit}) { +type TaxPickerProps = { + /** Collection of tax rates attached to a policy */ + taxRates: TaxRatesWithDefault; + + /** The selected tax rate of an expense */ + selectedTaxRate?: string; + + /** + * Safe area insets required for reflecting the portion of the view, + * that is not covered by navigation bars, tab bars, toolbars, and other ancestor views. + */ + insets?: EdgeInsets; + + /** Callback to fire when a tax is pressed */ + onSubmit: () => void; +}; + +function TaxPicker({selectedTaxRate = '', taxRates, insets, onSubmit}: TaxPickerProps) { const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); const {translate} = useLocalize(); @@ -40,10 +56,11 @@ function TaxPicker({selectedTaxRate, taxRates, insets, onSubmit}) { return taxRatesOptions; }, [taxRates, searchValue, selectedOptions]); - const selectedOptionKey = lodashGet(_.filter(lodashGet(sections, '[0].data', []), (taxRate) => taxRate.searchText === selectedTaxRate)[0], 'keyForList'); + const selectedOptionKey = sections?.[0]?.data?.find((taxRate) => taxRate.searchText === selectedTaxRate)?.keyForList; return ( `Welcome to ${roomName}!`, usePlusButton: ({additionalText}: UsePlusButtonParams) => `\nYou can also use the + button to ${additionalText}, or assign a task!`, iouTypes: { diff --git a/src/languages/es.ts b/src/languages/es.ts index 20f4cf8aeac8..83ed2ca1c89c 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -485,8 +485,10 @@ export default { beginningOfChatHistoryPolicyExpenseChatPartOne: '¡La colaboración entre ', beginningOfChatHistoryPolicyExpenseChatPartTwo: ' y ', beginningOfChatHistoryPolicyExpenseChatPartThree: ' empieza aquí! 🎉 Este es el lugar donde chatear, pedir dinero y pagar.', + beginningOfChatHistorySelfDM: 'Este es tu espacio personal. Úsalo para notas, tareas, borradores y recordatorios.', chatWithAccountManager: 'Chatea con tu gestor de cuenta aquí', sayHello: '¡Saluda!', + yourSpace: 'Tu espacio', welcomeToRoom: ({roomName}: WelcomeToRoomParams) => `¡Bienvenido a ${roomName}!`, usePlusButton: ({additionalText}: UsePlusButtonParams) => `\n¡También puedes usar el botón + de abajo para ${additionalText}, o asignar una tarea!`, iouTypes: { diff --git a/src/libs/Navigation/AppNavigator/AuthScreens.tsx b/src/libs/Navigation/AppNavigator/AuthScreens.tsx index 00c96d436496..30a8010ad801 100644 --- a/src/libs/Navigation/AppNavigator/AuthScreens.tsx +++ b/src/libs/Navigation/AppNavigator/AuthScreens.tsx @@ -81,8 +81,7 @@ Onyx.connect({ currentAccountID = value.accountID ?? -1; if (Navigation.isActiveRoute(ROUTES.SIGN_IN_MODAL)) { - // This means sign in in RHP was successful, so we can dismiss the modal and subscribe to user events - Navigation.dismissModal(); + // This means sign in in RHP was successful, so we can subscribe to user events User.subscribeToUserEvents(); } }, diff --git a/src/libs/Navigation/AppNavigator/ModalStackNavigators.tsx b/src/libs/Navigation/AppNavigator/ModalStackNavigators.tsx index 527d93c2a3db..545641957c9a 100644 --- a/src/libs/Navigation/AppNavigator/ModalStackNavigators.tsx +++ b/src/libs/Navigation/AppNavigator/ModalStackNavigators.tsx @@ -243,6 +243,7 @@ const SettingsModalStackNavigator = createModalStackNavigator require('../../../pages/workspace/reimburse/WorkspaceRateAndUnitPage/RatePage').default as React.ComponentType, [SCREENS.WORKSPACE.RATE_AND_UNIT_UNIT]: () => require('../../../pages/workspace/reimburse/WorkspaceRateAndUnitPage/UnitPage').default as React.ComponentType, [SCREENS.WORKSPACE.INVITE]: () => require('../../../pages/workspace/WorkspaceInvitePage').default as React.ComponentType, + [SCREENS.WORKSPACE.WORKFLOWS_APPROVER]: () => require('../../../pages/workspace/workflows/WorkspaceWorkflowsApproverPage').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.DESCRIPTION]: () => require('../../../pages/workspace/WorkspaceProfileDescriptionPage').default as React.ComponentType, 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 7e38ed99105e..618eddc9f62c 100755 --- a/src/libs/Navigation/linkingConfig/CENTRAL_PANE_TO_RHP_MAPPING.ts +++ b/src/libs/Navigation/linkingConfig/CENTRAL_PANE_TO_RHP_MAPPING.ts @@ -5,6 +5,7 @@ const CENTRAL_PANE_TO_RHP_MAPPING: Partial> = [SCREENS.WORKSPACE.PROFILE]: [SCREENS.WORKSPACE.NAME, SCREENS.WORKSPACE.CURRENCY, SCREENS.WORKSPACE.DESCRIPTION, SCREENS.WORKSPACE.SHARE], [SCREENS.WORKSPACE.REIMBURSE]: [SCREENS.WORKSPACE.RATE_AND_UNIT, SCREENS.WORKSPACE.RATE_AND_UNIT_RATE, SCREENS.WORKSPACE.RATE_AND_UNIT_UNIT], [SCREENS.WORKSPACE.MEMBERS]: [SCREENS.WORKSPACE.INVITE, SCREENS.WORKSPACE.INVITE_MESSAGE], + [SCREENS.WORKSPACE.WORKFLOWS]: [SCREENS.WORKSPACE.WORKFLOWS_APPROVER], }; export default CENTRAL_PANE_TO_RHP_MAPPING; diff --git a/src/libs/Navigation/linkingConfig/config.ts b/src/libs/Navigation/linkingConfig/config.ts index 7e0e6c028ff1..276829e8c691 100644 --- a/src/libs/Navigation/linkingConfig/config.ts +++ b/src/libs/Navigation/linkingConfig/config.ts @@ -262,6 +262,9 @@ const config: LinkingOptions['config'] = { [SCREENS.WORKSPACE.INVITE]: { path: ROUTES.WORKSPACE_INVITE.route, }, + [SCREENS.WORKSPACE.WORKFLOWS_APPROVER]: { + path: ROUTES.WORKSPACE_WORKFLOWS_APPROVER.route, + }, [SCREENS.WORKSPACE.INVITE_MESSAGE]: { path: ROUTES.WORKSPACE_INVITE_MESSAGE.route, }, diff --git a/src/libs/Navigation/types.ts b/src/libs/Navigation/types.ts index 6d680ac7e190..a1e558869ebe 100644 --- a/src/libs/Navigation/types.ts +++ b/src/libs/Navigation/types.ts @@ -63,6 +63,9 @@ type CentralPaneNavigatorParamList = { [SCREENS.WORKSPACE.WORKFLOWS]: { policyID: string; }; + [SCREENS.WORKSPACE.WORKFLOWS_APPROVER]: { + policyID: string; + }; [SCREENS.WORKSPACE.WORKFLOWS_AUTO_REPORTING_FREQUENCY]: { policyID: string; }; diff --git a/src/libs/OptionsListUtils.ts b/src/libs/OptionsListUtils.ts index 145c72ccd080..bdf1ba90583d 100644 --- a/src/libs/OptionsListUtils.ts +++ b/src/libs/OptionsListUtils.ts @@ -114,6 +114,7 @@ type GetOptionsConfig = { includeMultipleParticipantReports?: boolean; includePersonalDetails?: boolean; includeRecentReports?: boolean; + includeSelfDM?: boolean; sortByReportTypeInSearch?: boolean; searchInputValue?: string; showChatPreviewLine?: boolean; @@ -675,6 +676,8 @@ function createOption( result.tooltipText = ReportUtils.getReportParticipantsTitle(report.visibleChatMemberAccountIDs ?? []); result.isWaitingOnBankAccount = report.isWaitingOnBankAccount; result.policyID = report.policyID; + result.isSelfDM = ReportUtils.isSelfDM(report); + hasMultipleParticipants = personalDetailList.length > 1 || result.isChatRoom || result.isPolicyExpenseChat; subtitle = ReportUtils.getChatRoomSubtitle(report); @@ -804,6 +807,11 @@ function getEnabledCategoriesCount(options: PolicyCategories): number { return Object.values(options).filter((option) => option.enabled).length; } +function getSearchValueForPhoneOrEmail(searchTerm: string) { + const parsedPhoneNumber = PhoneNumber.parsePhoneNumber(LoginUtils.appendCountryCode(Str.removeSMSDomain(searchTerm))); + return parsedPhoneNumber.possible ? parsedPhoneNumber.number?.e164 ?? '' : searchTerm.toLowerCase(); +} + /** * Verifies that there is at least one enabled option */ @@ -1363,6 +1371,7 @@ function getOptions( transactionViolations = {}, includeTaxRates, taxRates, + includeSelfDM = false, }: GetOptionsConfig, ): GetOptions { if (includeCategories) { @@ -1439,8 +1448,8 @@ function getOptions( policies, doesReportHaveViolations, isInGSDMode: false, - excludeEmptyChats: false, + includeSelfDM, }); }); @@ -1467,7 +1476,9 @@ function getOptions( const isTaskReport = ReportUtils.isTaskReport(report); const isPolicyExpenseChat = ReportUtils.isPolicyExpenseChat(report); const isMoneyRequestReport = ReportUtils.isMoneyRequestReport(report); - const accountIDs = report.visibleChatMemberAccountIDs ?? []; + const isSelfDM = ReportUtils.isSelfDM(report); + // Currently, currentUser is not included in visibleChatMemberAccountIDs, so for selfDM we need to add the currentUser as participants. + const accountIDs = isSelfDM ? [currentUserAccountID ?? 0] : report.visibleChatMemberAccountIDs ?? []; if (isPolicyExpenseChat && report.isOwnPolicyExpenseChat && !includeOwnedWorkspaceChats) { return; @@ -1478,6 +1489,10 @@ function getOptions( return; } + if (isSelfDM && !includeSelfDM) { + return; + } + if (isThread && !includeThreads) { return; } @@ -1728,6 +1743,7 @@ function getSearchOptions(reports: Record, personalDetails: Onyx includeThreads: true, includeMoneyRequests: true, includeTasks: true, + includeSelfDM: true, }); Timing.end(CONST.TIMING.LOAD_SEARCH_OPTIONS); Performance.markEnd(CONST.TIMING.LOAD_SEARCH_OPTIONS); @@ -1803,6 +1819,7 @@ function getFilteredOptions( includeSelectedOptions = false, includeTaxRates = false, taxRates: TaxRatesWithDefault = {} as TaxRatesWithDefault, + includeSelfDM = false, ) { return getOptions(reports, personalDetails, { betas, @@ -1824,6 +1841,7 @@ function getFilteredOptions( includeSelectedOptions, includeTaxRates, taxRates, + includeSelfDM, }); } @@ -1857,6 +1875,7 @@ function getShareDestinationOptions( excludeLogins, includeOwnedWorkspaceChats, excludeUnknownUsers, + includeSelfDM: true, }); } @@ -1882,7 +1901,7 @@ function formatMemberForList(member: ReportUtils.OptionData): MemberForList { login: member.login ?? '', icons: member.icons, pendingAction: member.pendingAction, - reportID: member.reportID, + reportID: member.reportID ?? '', }; } @@ -2026,6 +2045,7 @@ export { getMemberInviteOptions, getHeaderMessage, getHeaderMessageForNonUserList, + getSearchValueForPhoneOrEmail, getPersonalDetailsForAccountIDs, getIOUConfirmationOptionsFromPayeePersonalDetail, getIOUConfirmationOptionsFromParticipants, diff --git a/src/libs/PersonalDetailsUtils.ts b/src/libs/PersonalDetailsUtils.ts index 1c517f42637f..9dd60eeebcef 100644 --- a/src/libs/PersonalDetailsUtils.ts +++ b/src/libs/PersonalDetailsUtils.ts @@ -24,9 +24,14 @@ Onyx.connect({ }, }); -function getDisplayNameOrDefault(passedPersonalDetails?: Partial | null, defaultValue = '', shouldFallbackToHidden = true): string { - const displayName = passedPersonalDetails?.displayName ? passedPersonalDetails.displayName.replace(CONST.REGEX.MERGED_ACCOUNT_PREFIX, '') : ''; +function getDisplayNameOrDefault(passedPersonalDetails?: Partial | null, defaultValue = '', shouldFallbackToHidden = true, shouldAddCurrentUserPostfix = false): string { + let displayName = passedPersonalDetails?.displayName ? passedPersonalDetails.displayName.replace(CONST.REGEX.MERGED_ACCOUNT_PREFIX, '') : ''; + if (shouldAddCurrentUserPostfix && !!displayName) { + displayName = `${displayName} (${Localize.translateLocal('common.you').toLowerCase()})`; + } + const fallbackValue = shouldFallbackToHidden ? Localize.translateLocal('common.hidden') : ''; + return displayName || defaultValue || fallbackValue; } @@ -56,6 +61,10 @@ function getPersonalDetailsByIDs(accountIDs: number[], currentUserAccountID: num return result; } +function getPersonalDetailByEmail(email: string): PersonalDetails | undefined { + return (Object.values(allPersonalDetails ?? {}) as PersonalDetails[]).find((detail) => detail?.login === email); +} + /** * Given a list of logins, find the associated personal detail and return related accountIDs. * @@ -263,6 +272,7 @@ export { isPersonalDetailsEmpty, getDisplayNameOrDefault, getPersonalDetailsByIDs, + getPersonalDetailByEmail, getAccountIDsByLogins, getLoginsByAccountIDs, getNewPersonalDetailsOnyxData, diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index a814c91ea8b2..b2fb456681cf 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -399,6 +399,7 @@ type OptionData = { notificationPreference?: NotificationPreference | null; isDisabled?: boolean | null; name?: string | null; + isSelfDM?: boolean | null; } & Report; type OnyxDataTaskAssigneeChat = { @@ -438,7 +439,7 @@ Onyx.connect({ currentUserEmail = value.email; currentUserAccountID = value.accountID; - isAnonymousUser = value.authTokenType === 'anonymousAccount'; + isAnonymousUser = value.authTokenType === CONST.AUTH_TOKEN_TYPE.ANONYMOUS; currentUserPrivateDomain = isEmailPublicDomain(currentUserEmail ?? '') ? '' : Str.extractEmailDomain(currentUserEmail ?? ''); }, }); @@ -909,6 +910,10 @@ function isDM(report: OnyxEntry): boolean { return isChatReport(report) && !getChatType(report); } +function isSelfDM(report: OnyxEntry): boolean { + return getChatType(report) === CONST.REPORT.CHAT_TYPE.SELF_DM; +} + /** * Only returns true if this is our main 1:1 DM report with Concierge */ @@ -1611,6 +1616,10 @@ function getIcons( return isPayer ? [managerIcon, ownerIcon] : [ownerIcon, managerIcon]; } + if (isSelfDM(report)) { + return getIconsForParticipants([currentUserAccountID ?? 0], personalDetails); + } + return getIconsForParticipants(report?.participantAccountIDs ?? [], personalDetails); } @@ -1633,7 +1642,7 @@ function getPersonalDetailsForAccountID(accountID: number): Partial { const accountID = Number(user?.accountID); // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing - const displayName = getDisplayNameForParticipant(accountID, isMultipleParticipantReport, shouldFallbackToHidden) || user?.login || ''; + const displayName = getDisplayNameForParticipant(accountID, isMultipleParticipantReport, shouldFallbackToHidden, shouldAddCurrentUserPostfix) || user?.login || ''; const avatar = UserUtils.getDefaultAvatar(accountID); let pronouns = user?.pronouns ?? undefined; @@ -2563,6 +2576,10 @@ function getReportName(report: OnyxEntry, policy: OnyxEntry = nu formattedName += ` (${Localize.translateLocal('common.archived')})`; } + if (isSelfDM(report)) { + formattedName = getDisplayNameForParticipant(currentUserAccountID, undefined, undefined, true); + } + if (formattedName) { return formattedName; } @@ -2619,6 +2636,11 @@ function getParentNavigationSubtitle(report: OnyxEntry): ParentNavigatio function navigateToDetailsPage(report: OnyxEntry) { const participantAccountIDs = report?.participantAccountIDs ?? []; + if (isSelfDM(report)) { + Navigation.navigate(ROUTES.PROFILE.getRoute(currentUserAccountID ?? 0)); + return; + } + if (isOneOnOneChat(report)) { Navigation.navigate(ROUTES.PROFILE.getRoute(participantAccountIDs[0])); return; @@ -3926,6 +3948,7 @@ function shouldReportBeInOptionList({ policies, excludeEmptyChats, doesReportHaveViolations, + includeSelfDM = false, }: { report: OnyxEntry; currentReportId: string; @@ -3934,6 +3957,7 @@ function shouldReportBeInOptionList({ policies: OnyxCollection; excludeEmptyChats: boolean; doesReportHaveViolations: boolean; + includeSelfDM?: boolean; }) { const isInDefaultMode = !isInGSDMode; // Exclude reports that have no data because there wouldn't be anything to show in the option item. @@ -3955,7 +3979,8 @@ function shouldReportBeInOptionList({ !isUserCreatedPolicyRoom(report) && !isArchivedRoom(report) && !isMoneyRequestReport(report) && - !isTaskReport(report)) + !isTaskReport(report) && + !isSelfDM(report)) ) { return false; } @@ -4017,6 +4042,10 @@ function shouldReportBeInOptionList({ return false; } + if (isSelfDM(report)) { + return includeSelfDM; + } + return true; } @@ -4244,7 +4273,7 @@ function hasIOUWaitingOnCurrentUserBankAccount(chatReport: OnyxEntry): b */ function canRequestMoney(report: OnyxEntry, policy: OnyxEntry, otherParticipants: number[]): boolean { // User cannot request money in chat thread or in task report or in chat room - if (isChatThread(report) || isTaskReport(report) || isChatRoom(report)) { + if (isChatThread(report) || isTaskReport(report) || isChatRoom(report) || isSelfDM(report)) { return false; } @@ -4304,7 +4333,7 @@ function canRequestMoney(report: OnyxEntry, policy: OnyxEntry, o */ function getMoneyRequestOptions(report: OnyxEntry, policy: OnyxEntry, reportParticipants: number[]): Array> { // In any thread or task report, we do not allow any new money requests yet - if (isChatThread(report) || isTaskReport(report)) { + if (isChatThread(report) || isTaskReport(report) || isSelfDM(report)) { return []; } @@ -4359,6 +4388,7 @@ function canLeaveRoom(report: OnyxEntry, isPolicyMember: boolean): boole report?.chatType === CONST.REPORT.CHAT_TYPE.POLICY_ANNOUNCE || report?.chatType === CONST.REPORT.CHAT_TYPE.POLICY_EXPENSE_CHAT || report?.chatType === CONST.REPORT.CHAT_TYPE.DOMAIN_ALL || + report?.chatType === CONST.REPORT.CHAT_TYPE.SELF_DM || !report?.chatType ) { // DM chats don't have a chatType @@ -5187,6 +5217,7 @@ export { getAddWorkspaceRoomOrChatReportErrors, getReportOfflinePendingActionAndErrors, isDM, + isSelfDM, getPolicy, getPolicyExpenseChatReportIDByOwner, getWorkspaceChats, diff --git a/src/libs/SidebarUtils.ts b/src/libs/SidebarUtils.ts index 35cf52a5ff99..51233838e6cf 100644 --- a/src/libs/SidebarUtils.ts +++ b/src/libs/SidebarUtils.ts @@ -23,7 +23,6 @@ import * as TaskUtils from './TaskUtils'; import * as UserUtils from './UserUtils'; const visibleReportActionItems: ReportActions = {}; -const lastReportActions: ReportActions = {}; Onyx.connect({ key: ONYXKEYS.COLLECTION.REPORT_ACTIONS, @@ -34,7 +33,6 @@ Onyx.connect({ const reportID = CollectionUtils.extractCollectionItemID(key); const actionsArray: ReportAction[] = ReportActionsUtils.getSortedReportActions(Object.values(actions)); - lastReportActions[reportID] = actionsArray[actionsArray.length - 1]; // The report is only visible if it is the last action not deleted that // does not match a closed or created state. @@ -92,6 +90,7 @@ function getOrderedReportIDs( policies, excludeEmptyChats: true, doesReportHaveViolations, + includeSelfDM: true, }); }); @@ -221,7 +220,14 @@ function getOptionData({ isDeletedParentAction: false, }; - const participantPersonalDetailList = Object.values(OptionsListUtils.getPersonalDetailsForAccountIDs(report.participantAccountIDs ?? [], personalDetails)) as PersonalDetails[]; + let participantAccountIDs = report.participantAccountIDs ?? []; + + // Currently, currentUser is not included in participantAccountIDs, so for selfDM we need to add the currentUser(report owner) as participants. + if (ReportUtils.isSelfDM(report)) { + participantAccountIDs = [report.ownerAccountID ?? 0]; + } + + const participantPersonalDetailList = Object.values(OptionsListUtils.getPersonalDetailsForAccountIDs(participantAccountIDs, personalDetails)) as PersonalDetails[]; const personalDetail = participantPersonalDetailList[0] ?? {}; const hasErrors = Object.keys(result.allReportErrors ?? {}).length !== 0; @@ -258,6 +264,7 @@ function getOptionData({ result.isAllowedToComment = ReportUtils.canUserPerformWriteAction(report); result.chatType = report.chatType; result.isDeletedParentAction = report.isDeletedParentAction; + result.isSelfDM = ReportUtils.isSelfDM(report); const hasMultipleParticipants = participantPersonalDetailList.length > 1 || result.isChatRoom || result.isPolicyExpenseChat || ReportUtils.isExpenseReport(report); const subtitle = ReportUtils.getChatRoomSubtitle(report); @@ -267,7 +274,12 @@ function getOptionData({ const formattedLogin = Str.isSMSLogin(login) ? LocalePhoneNumber.formatPhoneNumber(login) : login; // We only create tooltips for the first 10 users or so since some reports have hundreds of users, causing performance to degrade. - const displayNamesWithTooltips = ReportUtils.getDisplayNamesWithTooltips((participantPersonalDetailList || []).slice(0, 10), hasMultipleParticipants); + const displayNamesWithTooltips = ReportUtils.getDisplayNamesWithTooltips( + (participantPersonalDetailList || []).slice(0, 10), + hasMultipleParticipants, + undefined, + ReportUtils.isSelfDM(report), + ); // If the last actor's details are not currently saved in Onyx Collection, // then try to get that from the last report action if that action is valid @@ -289,14 +301,12 @@ function getOptionData({ let lastMessageText = lastMessageTextFromReport; - const reportAction = lastReportActions?.[report.reportID]; + const lastAction = visibleReportActionItems[report.reportID]; const isThreadMessage = - ReportUtils.isThread(report) && reportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT && reportAction?.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE; + ReportUtils.isThread(report) && lastAction?.actionName === CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT && lastAction?.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE; if ((result.isChatRoom || result.isPolicyExpenseChat || result.isThread || result.isTaskReport || isThreadMessage) && !result.isArchivedRoom) { - const lastAction = visibleReportActionItems[report.reportID]; - if (lastAction?.actionName === CONST.REPORT.ACTIONS.TYPE.RENAMED) { const newName = lastAction?.originalMessage?.newName ?? ''; result.alternateText = Localize.translate(preferredLocale, 'newRoomPage.roomRenamedTo', {newName}); diff --git a/src/libs/actions/Session/index.ts b/src/libs/actions/Session/index.ts index 013d86049150..76f335a3bec0 100644 --- a/src/libs/actions/Session/index.ts +++ b/src/libs/actions/Session/index.ts @@ -107,7 +107,7 @@ function signOut() { * Checks if the account is an anonymous account. */ function isAnonymousUser(): boolean { - return sessionAuthTokenType === 'anonymousAccount'; + return sessionAuthTokenType === CONST.AUTH_TOKEN_TYPE.ANONYMOUS; } function signOutAndRedirectToSignIn(shouldReplaceCurrentScreen?: boolean) { diff --git a/src/pages/ProfilePage.js b/src/pages/ProfilePage.js index 0b986adf1c6f..cf05b8e4ab28 100755 --- a/src/pages/ProfilePage.js +++ b/src/pages/ProfilePage.js @@ -95,9 +95,10 @@ const getPhoneNumber = (details) => { function ProfilePage(props) { const styles = useThemeStyles(); const accountID = Number(lodashGet(props.route.params, 'accountID', 0)); - const details = lodashGet(props.personalDetails, accountID, ValidationUtils.isValidAccountRoute(accountID) ? {} : {isloading: false}); + const isCurrentUser = props.session.accountID === accountID; - const displayName = PersonalDetailsUtils.getDisplayNameOrDefault(details); + const details = lodashGet(props.personalDetails, accountID, ValidationUtils.isValidAccountRoute(accountID) ? {} : {isloading: false}); + const displayName = PersonalDetailsUtils.getDisplayNameOrDefault(details, undefined, undefined, isCurrentUser); const avatar = lodashGet(details, 'avatar', UserUtils.getDefaultAvatar()); const fallbackIcon = lodashGet(details, 'fallbackIcon', ''); const login = lodashGet(details, 'login', ''); @@ -116,7 +117,6 @@ function ProfilePage(props) { const phoneNumber = getPhoneNumber(details); const phoneOrEmail = isSMSLogin ? getPhoneNumber(details) : login; - const isCurrentUser = props.session.accountID === accountID; const hasMinimumDetails = !_.isEmpty(details.avatar); const isLoading = lodashGet(details, 'isLoading', false) || _.isEmpty(details); @@ -130,7 +130,7 @@ function ProfilePage(props) { const navigateBackTo = lodashGet(props.route, 'params.backTo'); - const shouldShowNotificationPreference = !_.isEmpty(props.report) && props.report.notificationPreference !== CONST.REPORT.NOTIFICATION_PREFERENCE.HIDDEN; + const shouldShowNotificationPreference = !_.isEmpty(props.report) && !isCurrentUser && props.report.notificationPreference !== CONST.REPORT.NOTIFICATION_PREFERENCE.HIDDEN; const notificationPreference = shouldShowNotificationPreference ? props.translate(`notificationPreferencesPage.notificationPreferences.${props.report.notificationPreference}`) : ''; // eslint-disable-next-line rulesdir/prefer-early-return @@ -234,7 +234,7 @@ function ProfilePage(props) { shouldShowRightIcon /> )} - {!_.isEmpty(props.report) && ( + {!_.isEmpty(props.report) && !isCurrentUser && ( ReportUtils.isSelfDM(report), [report]); + useEffect(() => { - // Do not fetch private notes if isLoadingPrivateNotes is already defined, or if network is offline. - if (isPrivateNotesFetchTriggered || isOffline) { + // Do not fetch private notes if isLoadingPrivateNotes is already defined, or if the network is offline, or if the report is a self DM. + if (isPrivateNotesFetchTriggered || isOffline || isSelfDM) { return; } Report.getReportPrivateNote(report?.reportID ?? ''); - }, [report?.reportID, isOffline, isPrivateNotesFetchTriggered]); + }, [report?.reportID, isOffline, isPrivateNotesFetchTriggered, isSelfDM]); const menuItems: ReportDetailsPageMenuItem[] = useMemo(() => { const items: ReportDetailsPageMenuItem[] = []; + if (isSelfDM) { + return []; + } + if (!isGroupDMChat) { items.push({ key: CONST.REPORT_DETAILS_MENU_ITEM.SHARE_CODE, @@ -162,7 +168,7 @@ function ReportDetailsPage({policies, report, session, personalDetails}: ReportD } return items; - }, [isArchivedRoom, participants.length, isThread, isMoneyRequestReport, report, isGroupDMChat, isPolicyMember, isUserCreatedPolicyRoom, session]); + }, [isArchivedRoom, participants.length, isThread, isMoneyRequestReport, report, isGroupDMChat, isPolicyMember, isUserCreatedPolicyRoom, session, isSelfDM]); const displayNamesWithTooltips = useMemo(() => { const hasMultipleParticipants = participants.length > 1; diff --git a/src/pages/ReportParticipantsPage.tsx b/src/pages/ReportParticipantsPage.tsx index 48c147822f9b..24d696ca2fb0 100755 --- a/src/pages/ReportParticipantsPage.tsx +++ b/src/pages/ReportParticipantsPage.tsx @@ -87,7 +87,7 @@ function ReportParticipantsPage({report, personalDetails}: ReportParticipantsPag testID={ReportParticipantsPage.displayName} > {({safeAreaPaddingBottomStyle}) => ( - + Navigation.goBack(ROUTES.REPORT_WITH_ID_DETAILS.getRoute(report.reportID)) : undefined} title={translate( diff --git a/src/pages/SearchPage/index.js b/src/pages/SearchPage/index.js index 0c17e58837c1..1957b19abeb6 100644 --- a/src/pages/SearchPage/index.js +++ b/src/pages/SearchPage/index.js @@ -2,6 +2,7 @@ import PropTypes from 'prop-types'; import React, {useEffect, useMemo, useState} from 'react'; import {View} from 'react-native'; import {withOnyx} from 'react-native-onyx'; +import _ from 'underscore'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import {usePersonalDetails} from '@components/OnyxProvider'; import ScreenWrapper from '@components/ScreenWrapper'; @@ -93,7 +94,7 @@ function SearchPage({betas, reports, isSearchingForReports}) { if (recentReports.length > 0) { newSections.push({ - data: recentReports, + data: _.map(recentReports, (report) => ({...report, isBold: report.isUnread})), shouldShow: true, indexOffset, }); diff --git a/src/pages/home/HeaderView.js b/src/pages/home/HeaderView.js index faa70bb0633a..4f09a2da6243 100644 --- a/src/pages/home/HeaderView.js +++ b/src/pages/home/HeaderView.js @@ -93,10 +93,13 @@ function HeaderView(props) { const {translate} = useLocalize(); const theme = useTheme(); const styles = useThemeStyles(); - const participants = lodashGet(props.report, 'participantAccountIDs', []); + const isSelfDM = ReportUtils.isSelfDM(props.report); + // Currently, currentUser is not included in participantAccountIDs, so for selfDM, we need to add the currentUser as participants. + const participants = isSelfDM ? [props.session.accountID] : lodashGet(props.report, 'participantAccountIDs', []); const participantPersonalDetails = OptionsListUtils.getPersonalDetailsForAccountIDs(participants, props.personalDetails); const isMultipleParticipant = participants.length > 1; - const displayNamesWithTooltips = ReportUtils.getDisplayNamesWithTooltips(participantPersonalDetails, isMultipleParticipant); + const displayNamesWithTooltips = ReportUtils.getDisplayNamesWithTooltips(participantPersonalDetails, isMultipleParticipant, undefined, isSelfDM); + const isChatThread = ReportUtils.isChatThread(props.report); const isChatRoom = ReportUtils.isChatRoom(props.report); const isPolicyExpenseChat = ReportUtils.isPolicyExpenseChat(props.report); @@ -166,7 +169,7 @@ function HeaderView(props) { ), ); - const canJoinOrLeave = isChatThread || isUserCreatedPolicyRoom || canLeaveRoom; + const canJoinOrLeave = isChatThread || !isSelfDM || isUserCreatedPolicyRoom || canLeaveRoom; const canJoin = canJoinOrLeave && !isWhisperAction && props.report.notificationPreference === CONST.REPORT.NOTIFICATION_PREFERENCE.HIDDEN; const canLeave = canJoinOrLeave && ((isChatThread && props.report.notificationPreference.length) || isUserCreatedPolicyRoom || canLeaveRoom); if (canJoin) { @@ -194,7 +197,7 @@ function HeaderView(props) { ); const renderAdditionalText = () => { - if (shouldShowSubtitle() || isPersonalExpenseChat || _.isEmpty(policyName) || !_.isEmpty(parentNavigationSubtitleData)) { + if (shouldShowSubtitle() || isPersonalExpenseChat || _.isEmpty(policyName) || !_.isEmpty(parentNavigationSubtitleData) || isSelfDM) { return null; } return ( diff --git a/src/pages/home/report/ReportActionsView.js b/src/pages/home/report/ReportActionsView.js index cd625a667a7f..ed414de134f4 100755 --- a/src/pages/home/report/ReportActionsView.js +++ b/src/pages/home/report/ReportActionsView.js @@ -140,7 +140,7 @@ function ReportActionsView(props) { }, [props.network, isReportFullyVisible]); useEffect(() => { - const wasLoginChangedDetected = prevAuthTokenType === 'anonymousAccount' && !props.session.authTokenType; + const wasLoginChangedDetected = prevAuthTokenType === CONST.AUTH_TOKEN_TYPE.ANONYMOUS && !props.session.authTokenType; if (wasLoginChangedDetected && didUserLogInDuringSession() && isUserCreatedPolicyRoom(props.report)) { if (isReportFullyVisible) { openReportIfNecessary(); diff --git a/src/pages/home/report/ReportDetailsShareCodePage.tsx b/src/pages/home/report/ReportDetailsShareCodePage.tsx index 28b1d5cd71d7..712e6c3097be 100644 --- a/src/pages/home/report/ReportDetailsShareCodePage.tsx +++ b/src/pages/home/report/ReportDetailsShareCodePage.tsx @@ -1,4 +1,6 @@ import React from 'react'; +import * as ReportUtils from '@libs/ReportUtils'; +import NotFoundPage from '@pages/ErrorPage/NotFoundPage'; import ShareCodePage from '@pages/ShareCodePage'; import type {WithReportOrNotFoundProps} from './withReportOrNotFound'; import withReportOrNotFound from './withReportOrNotFound'; @@ -6,6 +8,9 @@ import withReportOrNotFound from './withReportOrNotFound'; type ReportDetailsShareCodePageProps = WithReportOrNotFoundProps; function ReportDetailsShareCodePage({report}: ReportDetailsShareCodePageProps) { + if (ReportUtils.isSelfDM(report)) { + return ; + } return ; } diff --git a/src/pages/home/report/ReportFooter.js b/src/pages/home/report/ReportFooter.js index 1761e135481a..3b9f8f03c320 100644 --- a/src/pages/home/report/ReportFooter.js +++ b/src/pages/home/report/ReportFooter.js @@ -16,7 +16,6 @@ import * as ReportUtils from '@libs/ReportUtils'; import reportPropTypes from '@pages/reportPropTypes'; import variables from '@styles/variables'; import * as Report from '@userActions/Report'; -import * as Session from '@userActions/Session'; import * as Task from '@userActions/Task'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -43,6 +42,12 @@ const propTypes = { /** Whether to show the compose input */ shouldShowComposeInput: PropTypes.bool, + /** Session info for the currently logged in user. */ + session: PropTypes.shape({ + /** Currently logged in user auth token type */ + authTokenType: PropTypes.string, + }), + ...windowDimensionsPropTypes, }; @@ -54,6 +59,7 @@ const defaultProps = { lastReportAction: null, isEmptyChat: true, shouldShowComposeInput: false, + session: {}, }; function ReportFooter(props) { @@ -61,7 +67,7 @@ function ReportFooter(props) { const {isOffline} = useNetwork(); const chatFooterStyles = {...styles.chatFooter, minHeight: !isOffline ? CONST.CHAT_FOOTER_MIN_HEIGHT : 0}; const isArchivedRoom = ReportUtils.isArchivedRoom(props.report); - const isAnonymousUser = Session.isAnonymousUser(); + const isAnonymousUser = props.session.authTokenType === CONST.AUTH_TOKEN_TYPE.ANONYMOUS; const isSmallSizeLayout = props.windowWidth - (props.isSmallScreenWidth ? 0 : variables.sideBarWidth) < variables.anonymousReportFooterBreakpoint; const hideComposer = !ReportUtils.canUserPerformWriteAction(props.report); @@ -159,6 +165,9 @@ export default compose( key: ONYXKEYS.SHOULD_SHOW_COMPOSE_INPUT, initialValue: false, }, + session: { + key: ONYXKEYS.SESSION, + }, }), )( memo( @@ -173,6 +182,7 @@ export default compose( prevProps.shouldShowComposeInput === nextProps.shouldShowComposeInput && prevProps.windowWidth === nextProps.windowWidth && prevProps.isSmallScreenWidth === nextProps.isSmallScreenWidth && - prevProps.isReportReadyForDisplay === nextProps.isReportReadyForDisplay, + prevProps.isReportReadyForDisplay === nextProps.isReportReadyForDisplay && + isEqual(prevProps.session, nextProps.session), ), ); diff --git a/src/pages/home/report/withReportAndPrivateNotesOrNotFound.tsx b/src/pages/home/report/withReportAndPrivateNotesOrNotFound.tsx index d8d461568a45..2c0edc77aad9 100644 --- a/src/pages/home/report/withReportAndPrivateNotesOrNotFound.tsx +++ b/src/pages/home/report/withReportAndPrivateNotesOrNotFound.tsx @@ -56,8 +56,8 @@ export default function (pageTitle: TranslationPaths) { // eslint-disable-next-line rulesdir/no-negated-variables const shouldShowNotFoundPage = useMemo(() => { - // Show not found view if the report is archived, or if the note is not of current user. - if (ReportUtils.isArchivedRoom(report) || isOtherUserNote) { + // Show not found view if the report is archived, or if the note is not of current user or if report is a self DM. + if (ReportUtils.isArchivedRoom(report) || isOtherUserNote || ReportUtils.isSelfDM(report)) { return true; } diff --git a/src/pages/iou/request/MoneyTemporaryForRefactorRequestParticipantsSelector.js b/src/pages/iou/request/MoneyTemporaryForRefactorRequestParticipantsSelector.js index 2865316b7fd5..95dda131eab7 100644 --- a/src/pages/iou/request/MoneyTemporaryForRefactorRequestParticipantsSelector.js +++ b/src/pages/iou/request/MoneyTemporaryForRefactorRequestParticipantsSelector.js @@ -145,6 +145,7 @@ function MoneyTemporaryForRefactorRequestParticipantsSelector({ personalDetails, true, ); + newSections.push(formatResults.section); indexOffset = formatResults.newIndexOffset; diff --git a/src/pages/settings/Report/NotificationPreferencePage.tsx b/src/pages/settings/Report/NotificationPreferencePage.tsx index 3977bdd0233d..af55ff994fcf 100644 --- a/src/pages/settings/Report/NotificationPreferencePage.tsx +++ b/src/pages/settings/Report/NotificationPreferencePage.tsx @@ -18,7 +18,7 @@ type NotificationPreferencePageProps = WithReportOrNotFoundProps & StackScreenPr function NotificationPreferencePage({report}: NotificationPreferencePageProps) { const {translate} = useLocalize(); - const shouldDisableNotificationPreferences = ReportUtils.isArchivedRoom(report); + const shouldDisableNotificationPreferences = ReportUtils.isArchivedRoom(report) || ReportUtils.isSelfDM(report); const notificationPreferenceOptions = Object.values(CONST.REPORT.NOTIFICATION_PREFERENCE) .filter((pref) => pref !== CONST.REPORT.NOTIFICATION_PREFERENCE.HIDDEN) .map((preference) => ({ diff --git a/src/pages/settings/Report/ReportSettingsPage.tsx b/src/pages/settings/Report/ReportSettingsPage.tsx index d738fc7ac3cf..0610d1e9057d 100644 --- a/src/pages/settings/Report/ReportSettingsPage.tsx +++ b/src/pages/settings/Report/ReportSettingsPage.tsx @@ -33,7 +33,7 @@ function ReportSettingsPage({report, policies}: ReportSettingsPageProps) { const shouldDisableRename = useMemo(() => ReportUtils.shouldDisableRename(report, linkedWorkspace), [report, linkedWorkspace]); const isMoneyRequestReport = ReportUtils.isMoneyRequestReport(report); - const shouldDisableSettings = isEmptyObject(report) || ReportUtils.isArchivedRoom(report); + const shouldDisableSettings = isEmptyObject(report) || ReportUtils.isArchivedRoom(report) || ReportUtils.isSelfDM(report); const shouldShowRoomName = !ReportUtils.isPolicyExpenseChat(report) && !ReportUtils.isChatThread(report); const notificationPreference = report?.notificationPreference && report.notificationPreference !== CONST.REPORT.NOTIFICATION_PREFERENCE.HIDDEN diff --git a/src/pages/signin/SignInModal.tsx b/src/pages/signin/SignInModal.tsx index 60350adae26a..e71ee6e9a988 100644 --- a/src/pages/signin/SignInModal.tsx +++ b/src/pages/signin/SignInModal.tsx @@ -1,23 +1,35 @@ -import React from 'react'; +import React, {useEffect} from 'react'; +import type {OnyxEntry} from 'react-native-onyx'; +import {withOnyx} from 'react-native-onyx'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import ScreenWrapper from '@components/ScreenWrapper'; import useStyleUtils from '@hooks/useStyleUtils'; import useTheme from '@hooks/useTheme'; import Navigation from '@libs/Navigation/Navigation'; -import * as Session from '@userActions/Session'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; import SCREENS from '@src/SCREENS'; +import type {Session} from '@src/types/onyx'; import SignInPage from './SignInPage'; -function SignInModal() { +type SignInModalOnyxProps = { + session: OnyxEntry; +}; + +type SignInModalProps = SignInModalOnyxProps; + +function SignInModal({session}: SignInModalProps) { const theme = useTheme(); const StyleUtils = useStyleUtils(); - if (!Session.isAnonymousUser()) { - // Signing in RHP is only for anonymous users - Navigation.isNavigationReady().then(() => { - Navigation.dismissModal(); - }); - } + useEffect(() => { + const isAnonymousUser = session?.authTokenType === CONST.AUTH_TOKEN_TYPE.ANONYMOUS; + if (!isAnonymousUser) { + // Signing in RHP is only for anonymous users + Navigation.isNavigationReady().then(() => Navigation.dismissModal()); + } + }, [session?.authTokenType]); + return ( ({ + session: {key: ONYXKEYS.SESSION}, +})(SignInModal); diff --git a/src/pages/workspace/WorkspaceInvitePage.tsx b/src/pages/workspace/WorkspaceInvitePage.tsx index 03fa78367eda..67bf6f8064da 100644 --- a/src/pages/workspace/WorkspaceInvitePage.tsx +++ b/src/pages/workspace/WorkspaceInvitePage.tsx @@ -1,6 +1,5 @@ import {useNavigation} from '@react-navigation/native'; import type {StackNavigationProp, StackScreenProps} from '@react-navigation/stack'; -import Str from 'expensify-common/lib/str'; import React, {useEffect, useMemo, useState} from 'react'; import type {SectionListData} from 'react-native'; import {View} from 'react-native'; @@ -176,8 +175,8 @@ function WorkspaceInvitePage({ filterSelectedOptions = selectedOptions.filter((option) => { const accountID = option.accountID; const isOptionInPersonalDetails = Object.values(personalDetails).some((personalDetail) => personalDetail.accountID === accountID); - const parsedPhoneNumber = PhoneNumber.parsePhoneNumber(LoginUtils.appendCountryCode(Str.removeSMSDomain(searchTerm))); - const searchValue = parsedPhoneNumber.possible ? parsedPhoneNumber.number?.e164 ?? '' : searchTerm.toLowerCase(); + + const searchValue = OptionsListUtils.getSearchValueForPhoneOrEmail(searchTerm); const isPartOfSearchTerm = !!option.text?.toLowerCase().includes(searchValue) || !!option.login?.toLowerCase().includes(searchValue); return isPartOfSearchTerm || isOptionInPersonalDetails; diff --git a/src/pages/workspace/workflows/WorkspaceWorkflowsApproverPage.tsx b/src/pages/workspace/workflows/WorkspaceWorkflowsApproverPage.tsx new file mode 100644 index 000000000000..52406a8033d2 --- /dev/null +++ b/src/pages/workspace/workflows/WorkspaceWorkflowsApproverPage.tsx @@ -0,0 +1,203 @@ +import React, {useCallback, useMemo, useState} from 'react'; +import type {SectionListData} from 'react-native'; +import {withOnyx} from 'react-native-onyx'; +import type {OnyxEntry} from 'react-native-onyx'; +import Badge from '@components/Badge'; +import FullPageNotFoundView from '@components/BlockingViews/FullPageNotFoundView'; +import HeaderWithBackButton from '@components/HeaderWithBackButton'; +import ScreenWrapper from '@components/ScreenWrapper'; +import SelectionList from '@components/SelectionList'; +import type {ListItem, Section} from '@components/SelectionList/types'; +import UserListItem from '@components/SelectionList/UserListItem'; +import useLocalize from '@hooks/useLocalize'; +import useNetwork from '@hooks/useNetwork'; +import useStyleUtils from '@hooks/useStyleUtils'; +import useThemeStyles from '@hooks/useThemeStyles'; +import compose from '@libs/compose'; +import {formatPhoneNumber} from '@libs/LocalePhoneNumber'; +import Log from '@libs/Log'; +import Navigation from '@libs/Navigation/Navigation'; +import * as OptionsListUtils from '@libs/OptionsListUtils'; +import * as PersonalDetailsUtils from '@libs/PersonalDetailsUtils'; +import * as PolicyUtils from '@libs/PolicyUtils'; +import * as UserUtils from '@libs/UserUtils'; +import withPolicyAndFullscreenLoading from '@pages/workspace/withPolicyAndFullscreenLoading'; +import type {WithPolicyAndFullscreenLoadingProps} from '@pages/workspace/withPolicyAndFullscreenLoading'; +import * as Policy from '@userActions/Policy'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {PersonalDetailsList, PolicyMember} from '@src/types/onyx'; +import {isEmptyObject} from '@src/types/utils/EmptyObject'; + +type WorkspaceWorkflowsApproverPageOnyxProps = { + /** All of the personal details for everyone */ + personalDetails: OnyxEntry; +}; + +type WorkspaceWorkflowsApproverPageProps = WorkspaceWorkflowsApproverPageOnyxProps & WithPolicyAndFullscreenLoadingProps; +type MemberOption = Omit & {accountID: number}; +type MembersSection = SectionListData>; + +function WorkspaceWorkflowsApproverPage({policy, policyMembers, personalDetails, isLoadingReportData = true}: WorkspaceWorkflowsApproverPageProps) { + const {translate} = useLocalize(); + const policyName = policy?.name ?? ''; + const [searchTerm, setSearchTerm] = useState(''); + const {isOffline} = useNetwork(); + const styles = useThemeStyles(); + const StyleUtils = useStyleUtils(); + + const isDeletedPolicyMember = useCallback( + (policyMember: PolicyMember) => !isOffline && policyMember.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE && isEmptyObject(policyMember.errors), + [isOffline], + ); + + const [formattedPolicyMembers, formattedApprover] = useMemo(() => { + const policyMemberDetails: MemberOption[] = []; + const approverDetails: MemberOption[] = []; + + Object.entries(policyMembers ?? {}).forEach(([accountIDKey, policyMember]) => { + const accountID = Number(accountIDKey); + if (isDeletedPolicyMember(policyMember)) { + return; + } + + const details = personalDetails?.[accountID]; + if (!details) { + Log.hmmm(`[WorkspaceMembersPage] no personal details found for policy member with accountID: ${accountID}`); + return; + } + + const isOwner = policy?.owner === details.login; + const isAdmin = policyMember.role === CONST.POLICY.ROLE.ADMIN; + + let roleBadge = null; + if (isOwner || isAdmin) { + roleBadge = ( + + ); + } + + const formattedMember = { + keyForList: accountIDKey, + accountID, + isSelected: policy?.approver === details.login, + isDisabled: policyMember.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE || !isEmptyObject(policyMember.errors), + text: formatPhoneNumber(PersonalDetailsUtils.getDisplayNameOrDefault(details)), + alternateText: formatPhoneNumber(details?.login ?? ''), + rightElement: roleBadge, + icons: [ + { + source: UserUtils.getAvatar(details.avatar, accountID), + name: formatPhoneNumber(details?.login ?? ''), + type: CONST.ICON_TYPE_AVATAR, + id: accountID, + }, + ], + errors: policyMember.errors, + pendingAction: policyMember.pendingAction, + }; + + if (policy?.approver === details.login) { + approverDetails.push(formattedMember); + } else { + policyMemberDetails.push(formattedMember); + } + }); + return [policyMemberDetails, approverDetails]; + }, [personalDetails, policyMembers, translate, policy?.approver, StyleUtils, isDeletedPolicyMember, policy?.owner, styles]); + + const sections: MembersSection[] = useMemo(() => { + const sectionsArray: MembersSection[] = []; + + if (searchTerm !== '') { + const filteredOptions = [...formattedApprover, ...formattedPolicyMembers].filter((option) => { + const searchValue = OptionsListUtils.getSearchValueForPhoneOrEmail(searchTerm); + return !!option.text?.toLowerCase().includes(searchValue) || !!option.login?.toLowerCase().includes(searchValue); + }); + return [ + { + title: undefined, + data: filteredOptions, + shouldShow: true, + }, + ]; + } + + sectionsArray.push({ + title: undefined, + data: formattedApprover, + shouldShow: formattedApprover.length > 0, + indexOffset: 0, + }); + + sectionsArray.push({ + title: translate('common.all'), + data: formattedPolicyMembers, + shouldShow: true, + indexOffset: formattedApprover.length, + }); + + return sectionsArray; + }, [formattedPolicyMembers, formattedApprover, searchTerm, translate]); + + const headerMessage = useMemo( + () => (searchTerm && !sections[0].data.length ? translate('common.noResultsFound') : ''), + + // eslint-disable-next-line react-hooks/exhaustive-deps + [translate, sections], + ); + + const setPolicyApprover = (member: MemberOption) => { + if (!policy?.approvalMode || !personalDetails?.[member.accountID]?.login) { + return; + } + const approver: string = personalDetails?.[member.accountID]?.login ?? policy.approver ?? policy.owner; + Policy.setWorkspaceApprovalMode(policy.id, approver, policy.approvalMode); + Navigation.goBack(); + }; + + return ( + + + + + + + ); +} + +WorkspaceWorkflowsApproverPage.displayName = 'WorkspaceWorkflowsApproverPage'; + +export default compose( + withOnyx({ + personalDetails: { + key: ONYXKEYS.PERSONAL_DETAILS_LIST, + }, + }), + withPolicyAndFullscreenLoading, +)(WorkspaceWorkflowsApproverPage); diff --git a/src/pages/workspace/workflows/WorkspaceWorkflowsPage.tsx b/src/pages/workspace/workflows/WorkspaceWorkflowsPage.tsx index d9974ed193be..0d8b1d2aced2 100644 --- a/src/pages/workspace/workflows/WorkspaceWorkflowsPage.tsx +++ b/src/pages/workspace/workflows/WorkspaceWorkflowsPage.tsx @@ -13,10 +13,9 @@ import useStyleUtils from '@hooks/useStyleUtils'; import useThemeStyles from '@hooks/useThemeStyles'; import useWindowDimensions from '@hooks/useWindowDimensions'; import Navigation from '@libs/Navigation/Navigation'; -import * as OptionsListUtils from '@libs/OptionsListUtils'; import Permissions from '@libs/Permissions'; +import * as PersonalDetailsUtils from '@libs/PersonalDetailsUtils'; import * as PolicyUtils from '@libs/PolicyUtils'; -import * as ReportUtils from '@libs/ReportUtils'; import type {CentralPaneNavigatorParamList} from '@navigation/types'; import type {WithPolicyProps} from '@pages/workspace/withPolicy'; import withPolicy from '@pages/workspace/withPolicy'; @@ -45,8 +44,8 @@ function WorkspaceWorkflowsPage({policy, betas, route}: WorkspaceWorkflowsPagePr const {isSmallScreenWidth} = useWindowDimensions(); const {isOffline} = useNetwork(); - const ownerPersonalDetails = ReportUtils.getDisplayNamesWithTooltips(OptionsListUtils.getPersonalDetailsForAccountIDs([policy?.ownerAccountID ?? 0], CONST.EMPTY_OBJECT), false); - const policyOwnerDisplayName = ownerPersonalDetails[0]?.displayName; + const policyApproverEmail = policy?.approver; + const policyApproverName = useMemo(() => PersonalDetailsUtils.getPersonalDetailByEmail(policyApproverEmail ?? '')?.displayName ?? policyApproverEmail, [policyApproverEmail]); const containerStyle = useMemo(() => [styles.ph8, styles.mhn8, styles.ml11, styles.pv3, styles.pr0, styles.pl4, styles.mr0, styles.widthAuto, styles.mt4], [styles]); const canUseDelayedSubmission = Permissions.canUseWorkflowsDelayedSubmission(betas); @@ -96,9 +95,8 @@ function WorkspaceWorkflowsPage({policy, betas, route}: WorkspaceWorkflowsPagePr title={translate('workflowsPage.approver')} titleStyle={styles.textLabelSupportingNormal} descriptionTextStyle={styles.textNormalThemeText} - description={policyOwnerDisplayName ?? ''} - // onPress={() => Navigation.navigate(ROUTES.WORKSPACE_WORKFLOWS_APPROVER.getRoute(route.params.policyID))} - // TODO will be done in https://github.com/Expensify/Expensify/issues/368334 + description={policyApproverName ?? ''} + onPress={() => Navigation.navigate(ROUTES.WORKSPACE_WORKFLOWS_APPROVER.getRoute(route.params.policyID))} shouldShowRightIcon wrapperStyle={containerStyle} hoverAndPressStyle={[styles.mr0, styles.br2]} @@ -132,11 +130,11 @@ function WorkspaceWorkflowsPage({policy, betas, route}: WorkspaceWorkflowsPagePr }, ], [ + policyApproverName, policy, route.params.policyID, styles, translate, - policyOwnerDisplayName, containerStyle, isOffline, StyleUtils, diff --git a/tests/e2e/TestSpec.yml b/tests/e2e/TestSpec.yml index e0dcd2b9b66d..333d3af7d03d 100644 --- a/tests/e2e/TestSpec.yml +++ b/tests/e2e/TestSpec.yml @@ -21,8 +21,7 @@ phases: test: commands: - cd zip - - npm install underscore ts-node typescript - - npx ts-node e2e/testRunner.js -- --mainAppPath app-e2eRelease.apk --deltaAppPath app-e2edeltaRelease.apk + - node testRunner.js -- --mainAppPath app-e2eRelease.apk --deltaAppPath app-e2edeltaRelease.apk artifacts: - $WORKING_DIRECTORY diff --git a/tests/e2e/compare/output/console.ts b/tests/e2e/compare/output/console.ts index 38d01292a791..de8e5d913893 100644 --- a/tests/e2e/compare/output/console.ts +++ b/tests/e2e/compare/output/console.ts @@ -1,7 +1,13 @@ +import type {Stats} from '../../measure/math'; import * as format from './format'; type Entry = { name: string; + baseline: Stats; + current: Stats; + diff: number; + relativeDurationDiff: number; + isDurationDiffOfSignificance: boolean; }; type Data = { @@ -29,3 +35,5 @@ export default (data: Data) => { console.debug(''); }; + +export type {Entry}; diff --git a/tests/e2e/compare/output/format.js b/tests/e2e/compare/output/format.ts similarity index 80% rename from tests/e2e/compare/output/format.js rename to tests/e2e/compare/output/format.ts index 18b49cf03028..40c9e74d6247 100644 --- a/tests/e2e/compare/output/format.js +++ b/tests/e2e/compare/output/format.ts @@ -2,13 +2,14 @@ * Utility for formatting text for result outputs. * from: https://github.com/callstack/reassure/blob/main/packages/reassure-compare/src/utils/format.ts */ +import type {Entry} from './console'; -const formatPercent = (value) => { +const formatPercent = (value: number): string => { const valueAsPercent = value * 100; return `${valueAsPercent.toFixed(1)}%`; }; -const formatPercentChange = (value) => { +const formatPercentChange = (value: number): string => { const absValue = Math.abs(value); // Round to zero @@ -19,9 +20,9 @@ const formatPercentChange = (value) => { return `${value >= 0 ? '+' : '-'}${formatPercent(absValue)}`; }; -const formatDuration = (duration) => `${duration.toFixed(3)} ms`; +const formatDuration = (duration: number): string => `${duration.toFixed(3)} ms`; -const formatDurationChange = (value) => { +const formatDurationChange = (value: number): string => { if (value > 0) { return `+${formatDuration(value)}`; } @@ -31,7 +32,7 @@ const formatDurationChange = (value) => { return '0 ms'; }; -const formatChange = (value) => { +const formatChange = (value: number): string => { if (value > 0) { return `+${value}`; } @@ -41,7 +42,7 @@ const formatChange = (value) => { return '0'; }; -const getDurationSymbols = (entry) => { +const getDurationSymbols = (entry: Entry): string => { if (!entry.isDurationDiffOfSignificance) { if (entry.relativeDurationDiff > 0.15) { return '🟡'; @@ -68,7 +69,7 @@ const getDurationSymbols = (entry) => { return ''; }; -const formatDurationDiffChange = (entry) => { +const formatDurationDiffChange = (entry: Entry): string => { const {baseline, current} = entry; let output = `${formatDuration(baseline.mean)} → ${formatDuration(current.mean)}`; diff --git a/tests/e2e/measure/math.ts b/tests/e2e/measure/math.ts index e1c0cb981a0c..d444ab0e79da 100644 --- a/tests/e2e/measure/math.ts +++ b/tests/e2e/measure/math.ts @@ -49,3 +49,5 @@ const getStats = (entries: Entries): Stats => { }; export default getStats; + +export type {Stats}; diff --git a/tests/unit/CalendarPickerTest.js b/tests/unit/CalendarPickerTest.tsx similarity index 92% rename from tests/unit/CalendarPickerTest.js rename to tests/unit/CalendarPickerTest.tsx index 3aab3a13c1c3..8beb02ec80c4 100644 --- a/tests/unit/CalendarPickerTest.js +++ b/tests/unit/CalendarPickerTest.tsx @@ -1,19 +1,22 @@ +import type ReactNavigationNative from '@react-navigation/native'; import {fireEvent, render, within} from '@testing-library/react-native'; import {addMonths, addYears, subMonths, subYears} from 'date-fns'; -import CalendarPicker from '../../src/components/DatePicker/CalendarPicker'; -import CONST from '../../src/CONST'; -import DateUtils from '../../src/libs/DateUtils'; +import type {ComponentType} from 'react'; +import CalendarPicker from '@components/DatePicker/CalendarPicker'; +import type {WithLocalizeProps} from '@components/withLocalize'; +import DateUtils from '@libs/DateUtils'; +import CONST from '@src/CONST'; const monthNames = DateUtils.getMonthNames(CONST.LOCALES.EN); jest.mock('@react-navigation/native', () => ({ - ...jest.requireActual('@react-navigation/native'), + ...jest.requireActual('@react-navigation/native'), useNavigation: () => ({navigate: jest.fn()}), createNavigationContainerRef: jest.fn(), })); -jest.mock('../../src/components/withLocalize', () => (Component) => { - function WrappedComponent(props) { +jest.mock('../../src/components/withLocalize', () => (Component: ComponentType) => { + function WrappedComponent(props: Omit) { return (