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/__mocks__/react-native-safe-area-context.js b/__mocks__/react-native-safe-area-context.js deleted file mode 100644 index b31ed670b81c..000000000000 --- a/__mocks__/react-native-safe-area-context.js +++ /dev/null @@ -1,52 +0,0 @@ -import React, {forwardRef} from 'react'; -import {View} from 'react-native'; - -const insets = { - top: 0, - right: 0, - bottom: 0, - left: 0, -}; - -function withSafeAreaInsets(WrappedComponent) { - function WithSafeAreaInsets(props) { - return ( - - ); - } - - const WithSafeAreaInsetsWithRef = forwardRef((props, ref) => ( - - )); - - WithSafeAreaInsetsWithRef.displayName = 'WithSafeAreaInsetsWithRef'; - - return WithSafeAreaInsetsWithRef; -} - -const SafeAreaView = View; -const SafeAreaProvider = (props) => props.children; -const SafeAreaConsumer = (props) => props.children(insets); -const SafeAreaInsetsContext = { - Consumer: SafeAreaConsumer, -}; - -const useSafeAreaFrame = jest.fn(() => ({ - x: 0, - y: 0, - width: 390, - height: 844, -})); -const useSafeAreaInsets = jest.fn(() => insets); - -export {SafeAreaProvider, SafeAreaConsumer, SafeAreaInsetsContext, withSafeAreaInsets, SafeAreaView, useSafeAreaFrame, useSafeAreaInsets}; diff --git a/__mocks__/react-native-safe-area-context.tsx b/__mocks__/react-native-safe-area-context.tsx new file mode 100644 index 000000000000..b789c90f87e8 --- /dev/null +++ b/__mocks__/react-native-safe-area-context.tsx @@ -0,0 +1,61 @@ +import type {ForwardedRef, ReactNode} from 'react'; +import React, {forwardRef} from 'react'; +import {View} from 'react-native'; +import type {EdgeInsets, useSafeAreaFrame as LibUseSafeAreaFrame, WithSafeAreaInsetsProps} from 'react-native-safe-area-context'; +import type ChildrenProps from '@src/types/utils/ChildrenProps'; + +type SafeAreaProviderProps = ChildrenProps; +type SafeAreaConsumerProps = { + children?: (insets: EdgeInsets) => ReactNode; +}; +type SafeAreaInsetsContextValue = { + Consumer: (props: SafeAreaConsumerProps) => ReactNode; +}; + +const insets: EdgeInsets = { + top: 0, + right: 0, + bottom: 0, + left: 0, +}; + +function withSafeAreaInsets(WrappedComponent: React.ComponentType}>) { + function WithSafeAreaInsets(props: WithSafeAreaInsetsProps & {forwardedRef: React.ForwardedRef}) { + return ( + + ); + } + + const WithSafeAreaInsetsWithRef = forwardRef((props: WithSafeAreaInsetsProps, ref: ForwardedRef) => ( + + )); + + return WithSafeAreaInsetsWithRef; +} + +const SafeAreaView = View; +const SafeAreaProvider = (props: SafeAreaProviderProps) => props.children; +const SafeAreaConsumer = (props: SafeAreaConsumerProps) => props.children?.(insets); +const SafeAreaInsetsContext: SafeAreaInsetsContextValue = { + Consumer: SafeAreaConsumer, +}; + +const useSafeAreaFrame: jest.Mock> = jest.fn(() => ({ + x: 0, + y: 0, + width: 390, + height: 844, +})); +const useSafeAreaInsets: jest.Mock = jest.fn(() => insets); + +export {SafeAreaProvider, SafeAreaConsumer, SafeAreaInsetsContext, withSafeAreaInsets, SafeAreaView, useSafeAreaFrame, useSafeAreaInsets}; diff --git a/docs/assets/images/moderation-context-menu.png b/docs/assets/images/moderation-context-menu.png index e76a09ce8153..bc96aa55037f 100644 Binary files a/docs/assets/images/moderation-context-menu.png and b/docs/assets/images/moderation-context-menu.png differ diff --git a/docs/assets/images/moderation-flag-page.png b/docs/assets/images/moderation-flag-page.png index e28ff940322d..256c1678fe7d 100644 Binary files a/docs/assets/images/moderation-flag-page.png and b/docs/assets/images/moderation-flag-page.png differ diff --git a/package-lock.json b/package-lock.json index a18846dec51a..44cabdeb2cb7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -97,7 +97,7 @@ "react-native-linear-gradient": "^2.8.1", "react-native-localize": "^2.2.6", "react-native-modal": "^13.0.0", - "react-native-onyx": "2.0.7", + "react-native-onyx": "2.0.10", "react-native-pager-view": "6.2.2", "react-native-pdf": "6.7.3", "react-native-performance": "^5.1.0", @@ -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", @@ -44490,9 +44483,9 @@ } }, "node_modules/react-native-onyx": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/react-native-onyx/-/react-native-onyx-2.0.7.tgz", - "integrity": "sha512-UGMUTSFxYEzNn3wuCGzaf0t6D5XwcE+3J2pYj7wPlbskdcHVLijZZEwgSSDBF7hgNfCuZ+ImetskPNktnf9hkg==", + "version": "2.0.10", + "resolved": "https://registry.npmjs.org/react-native-onyx/-/react-native-onyx-2.0.10.tgz", + "integrity": "sha512-XHJdKBZnUyoRKrBgZlv/p6ehuFvqXqwqQlapmVwwIU40KQQes58gPy+8HnRndT3CdAeElVWZnw/BUMtiD/F3Xw==", "dependencies": { "ascii-table": "0.0.9", "fast-equals": "^4.0.3", diff --git a/package.json b/package.json index e6d311934524..5ce77f47aedc 100644 --- a/package.json +++ b/package.json @@ -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", @@ -145,7 +146,7 @@ "react-native-linear-gradient": "^2.8.1", "react-native-localize": "^2.2.6", "react-native-modal": "^13.0.0", - "react-native-onyx": "2.0.7", + "react-native-onyx": "2.0.10", "react-native-pager-view": "6.2.2", "react-native-pdf": "6.7.3", "react-native-performance": "^5.1.0", @@ -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 1489df5c051a..6e49ad9c12bc 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', @@ -1471,6 +1472,8 @@ const CONST = { ALPHABETIC_AND_LATIN_CHARS: /^[\p{Script=Latin} ]*$/u, NON_ALPHABETIC_AND_NON_LATIN_CHARS: /[^\p{Script=Latin}]/gu, ACCENT_LATIN_CHARS: /[\u00C0-\u017F]/g, + INVALID_DISPLAY_NAME_LHN: /[^\p{L}\p{N}\u00C0-\u017F\s-]/gu, + INVALID_DISPLAY_NAME_ONLY_LHN: /^[^\p{L}\p{N}\u00C0-\u017F]$/gu, POSITIVE_INTEGER: /^\d+$/, PO_BOX: /\b[P|p]?(OST|ost)?\.?\s*[O|o|0]?(ffice|FFICE)?\.?\s*[B|b][O|o|0]?[X|x]?\.?\s+[#]?(\d+)\b/, ANY_VALUE: /^.+$/, 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/LHNOptionsList/LHNOptionsList.tsx b/src/components/LHNOptionsList/LHNOptionsList.tsx index 27f424ad1b70..f5545f402b14 100644 --- a/src/components/LHNOptionsList/LHNOptionsList.tsx +++ b/src/components/LHNOptionsList/LHNOptionsList.tsx @@ -32,6 +32,7 @@ function LHNOptionsList({ draftComments = {}, transactionViolations = {}, onFirstItemRendered = () => {}, + reportIDsWithErrors = {}, }: LHNOptionsListProps) { const styles = useThemeStyles(); const {canUseViolations} = usePermissions(); @@ -63,6 +64,7 @@ function LHNOptionsList({ const itemComment = draftComments?.[`${ONYXKEYS.COLLECTION.REPORT_DRAFT_COMMENT}${reportID}`] ?? ''; const sortedReportActions = ReportActionsUtils.getSortedReportActionsForDisplay(itemReportActions); const lastReportAction = sortedReportActions[0]; + const reportErrors = reportIDsWithErrors[reportID] ?? {}; // Get the transaction for the last report action let lastReportActionTransactionID = ''; @@ -91,6 +93,7 @@ function LHNOptionsList({ transactionViolations={transactionViolations} canUseViolations={canUseViolations} onLayout={onLayoutItem} + reportErrors={reportErrors} /> ); }, @@ -109,6 +112,7 @@ function LHNOptionsList({ transactionViolations, canUseViolations, onLayoutItem, + reportIDsWithErrors, ], ); diff --git a/src/components/LHNOptionsList/OptionRowLHNData.tsx b/src/components/LHNOptionsList/OptionRowLHNData.tsx index a18d5a8ec1ec..a3394190d0c1 100644 --- a/src/components/LHNOptionsList/OptionRowLHNData.tsx +++ b/src/components/LHNOptionsList/OptionRowLHNData.tsx @@ -28,6 +28,7 @@ function OptionRowLHNData({ lastReportActionTransaction = {}, transactionViolations, canUseViolations, + reportErrors, ...propsToForward }: OptionRowLHNDataProps) { const reportID = propsToForward.reportID; @@ -40,11 +41,11 @@ function OptionRowLHNData({ // Note: ideally we'd have this as a dependent selector in onyx! const item = SidebarUtils.getOptionData({ report: fullReport, - reportActions, personalDetails, preferredLocale: preferredLocale ?? CONST.LOCALES.DEFAULT, policy, parentReportAction, + reportErrors, hasViolations: !!hasViolations, }); if (deepEqual(item, optionItemRef.current)) { @@ -69,6 +70,7 @@ function OptionRowLHNData({ transactionViolations, canUseViolations, receiptTransactions, + reportErrors, ]); useEffect(() => { diff --git a/src/components/LHNOptionsList/types.ts b/src/components/LHNOptionsList/types.ts index 58bea97f04c9..c122ab018392 100644 --- a/src/components/LHNOptionsList/types.ts +++ b/src/components/LHNOptionsList/types.ts @@ -7,6 +7,7 @@ import type {CurrentReportIDContextValue} from '@components/withCurrentReportID' import type CONST from '@src/CONST'; import type {OptionData} from '@src/libs/ReportUtils'; import type {Locale, PersonalDetailsList, Policy, Report, ReportAction, ReportActions, Transaction, TransactionViolation} from '@src/types/onyx'; +import type * as OnyxCommon from '@src/types/onyx/OnyxCommon'; import type {EmptyObject} from '@src/types/utils/EmptyObject'; type OptionMode = ValueOf; @@ -58,6 +59,9 @@ type CustomLHNOptionsListProps = { /** Callback to fire when the list is laid out */ onFirstItemRendered: () => void; + + /** Report IDs with errors mapping to their corresponding error objects */ + reportIDsWithErrors: Record; }; type LHNOptionsListProps = CustomLHNOptionsListProps & CurrentReportIDContextValue & LHNOptionsListOnyxProps; @@ -113,6 +117,9 @@ type OptionRowLHNDataProps = { /** Callback to execute when the OptionList lays out */ onLayout?: (event: LayoutChangeEvent) => void; + + /** The report errors */ + reportErrors: OnyxCommon.Errors | undefined; }; type OptionRowLHNProps = { 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/ReportWelcomeText.tsx b/src/components/ReportWelcomeText.tsx index 6ade416e82ca..e9bbd0f27bdc 100644 --- a/src/components/ReportWelcomeText.tsx +++ b/src/components/ReportWelcomeText.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, {useMemo} from 'react'; import {View} from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; import {withOnyx} from 'react-native-onyx'; @@ -35,7 +35,8 @@ function ReportWelcomeText({report, policy, personalDetails}: ReportWelcomeTextP const styles = useThemeStyles(); const isPolicyExpenseChat = ReportUtils.isPolicyExpenseChat(report); const isChatRoom = ReportUtils.isChatRoom(report); - const isDefault = !(isChatRoom || isPolicyExpenseChat); + const isSelfDM = ReportUtils.isSelfDM(report); + const isDefault = !(isChatRoom || isPolicyExpenseChat || isSelfDM); const participantAccountIDs = report?.participantAccountIDs ?? []; const isMultipleParticipant = participantAccountIDs.length > 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/OptionsListUtils.ts b/src/libs/OptionsListUtils.ts index 342006eca710..631bbfe4aa4a 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; @@ -480,7 +481,7 @@ function getSearchText( /** * Get an object of error messages keyed by microtime by combining all error objects related to the report. */ -function getAllReportErrors(report: OnyxEntry, reportActions: OnyxEntry): OnyxCommon.Errors { +function getAllReportErrors(report: OnyxEntry, reportActions: OnyxEntry, transactions: OnyxCollection = allTransactions): OnyxCommon.Errors { const reportErrors = report?.errors ?? {}; const reportErrorFields = report?.errorFields ?? {}; const reportActionErrors: OnyxCommon.ErrorFields = Object.values(reportActions ?? {}).reduce( @@ -492,7 +493,7 @@ function getAllReportErrors(report: OnyxEntry, reportActions: OnyxEntry< if (parentReportAction?.actorAccountID === currentUserAccountID && ReportActionUtils.isTransactionThread(parentReportAction)) { const transactionID = parentReportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.IOU ? parentReportAction?.originalMessage?.IOUTransactionID : null; - const transaction = allTransactions?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`]; + const transaction = transactions?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`]; if (TransactionUtils.hasMissingSmartscanFields(transaction ?? null) && !ReportUtils.isSettled(transaction?.reportID)) { reportActionErrors.smartscan = ErrorUtils.getMicroSecondOnyxError('report.genericSmartscanFailureMessage'); } @@ -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); @@ -1368,6 +1371,7 @@ function getOptions( transactionViolations = {}, includeTaxRates, taxRates, + includeSelfDM = false, }: GetOptionsConfig, ): GetOptions { if (includeCategories) { @@ -1444,8 +1448,8 @@ function getOptions( policies, doesReportHaveViolations, isInGSDMode: false, - excludeEmptyChats: false, + includeSelfDM, }); }); @@ -1472,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; @@ -1483,6 +1489,10 @@ function getOptions( return; } + if (isSelfDM && !includeSelfDM) { + return; + } + if (isThread && !includeThreads) { return; } @@ -1733,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); @@ -1808,6 +1819,7 @@ function getFilteredOptions( includeSelectedOptions = false, includeTaxRates = false, taxRates: TaxRatesWithDefault = {} as TaxRatesWithDefault, + includeSelfDM = false, ) { return getOptions(reports, personalDetails, { betas, @@ -1829,6 +1841,7 @@ function getFilteredOptions( includeSelectedOptions, includeTaxRates, taxRates, + includeSelfDM, }); } @@ -1862,6 +1875,7 @@ function getShareDestinationOptions( excludeLogins, includeOwnedWorkspaceChats, excludeUnknownUsers, + includeSelfDM: true, }); } diff --git a/src/libs/PersonalDetailsUtils.ts b/src/libs/PersonalDetailsUtils.ts index 4be9ad81184b..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; } diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index f64123aceaf8..284c56ce2c1e 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 = { @@ -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 @@ -4493,19 +4523,6 @@ function getReportOfflinePendingActionAndErrors(report: OnyxEntry): Repo return {reportPendingAction, reportErrors}; } -function getPolicyExpenseChatReportIDByOwner(policyOwner: string): string | null { - const policyWithOwner = Object.values(allPolicies ?? {}).find((policy) => policy?.owner === policyOwner); - if (!policyWithOwner) { - return null; - } - - const expenseChat = Object.values(allReports ?? {}).find((report) => isPolicyExpenseChat(report) && report?.policyID === policyWithOwner.id); - if (!expenseChat) { - return null; - } - return expenseChat.reportID; -} - /** * Check if the report can create the request with type is iouType */ @@ -5187,8 +5204,8 @@ export { getAddWorkspaceRoomOrChatReportErrors, getReportOfflinePendingActionAndErrors, isDM, + isSelfDM, getPolicy, - getPolicyExpenseChatReportIDByOwner, getWorkspaceChats, shouldDisableRename, hasSingleParticipant, diff --git a/src/libs/SidebarUtils.ts b/src/libs/SidebarUtils.ts index 35cf52a5ff99..4fa84d3b7c86 100644 --- a/src/libs/SidebarUtils.ts +++ b/src/libs/SidebarUtils.ts @@ -7,6 +7,7 @@ import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type {PersonalDetails, PersonalDetailsList, TransactionViolation} from '@src/types/onyx'; import type Beta from '@src/types/onyx/Beta'; +import type * as OnyxCommon from '@src/types/onyx/OnyxCommon'; import type Policy from '@src/types/onyx/Policy'; import type Report from '@src/types/onyx/Report'; import type {ReportActions} from '@src/types/onyx/ReportAction'; @@ -23,7 +24,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 +34,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. @@ -59,6 +58,13 @@ function compareStringDates(a: string, b: string): 0 | 1 | -1 { return 0; } +function filterDisplayName(displayName: string): string { + if (CONST.REGEX.INVALID_DISPLAY_NAME_ONLY_LHN.test(displayName)) { + return displayName; + } + return displayName.replace(CONST.REGEX.INVALID_DISPLAY_NAME_LHN, '').trim(); +} + /** * @returns An array of reportIDs sorted in the proper order */ @@ -68,22 +74,27 @@ function getOrderedReportIDs( betas: Beta[], policies: Record, priorityMode: ValueOf, - allReportActions: OnyxCollection, + allReportActions: OnyxCollection, transactionViolations: OnyxCollection, currentPolicyID = '', policyMemberAccountIDs: number[] = [], + reportIDsWithErrors: Record = {}, + canUseViolations = false, ): string[] { const isInGSDMode = priorityMode === CONST.PRIORITY_MODE.GSD; const isInDefaultMode = !isInGSDMode; const allReportsDictValues = Object.values(allReports); + const reportIDsWithViolations = new Set(); + // Filter out all the reports that shouldn't be displayed let reportsToDisplay = allReportsDictValues.filter((report) => { const parentReportActionsKey = `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report?.parentReportID}`; - const parentReportActions = allReportActions?.[parentReportActionsKey]; - const parentReportAction = parentReportActions?.find((action) => action && report && action?.reportActionID === report?.parentReportActionID); - const doesReportHaveViolations = - betas.includes(CONST.BETAS.VIOLATIONS) && !!parentReportAction && ReportUtils.doesTransactionThreadHaveViolations(report, transactionViolations, parentReportAction); + const parentReportAction = allReportActions?.[parentReportActionsKey]?.[report.parentReportActionID ?? '']; + const doesReportHaveViolations = canUseViolations && !!parentReportAction && ReportUtils.doesTransactionThreadHaveViolations(report, transactionViolations, parentReportAction); + if (doesReportHaveViolations) { + reportIDsWithViolations.add(report.reportID); + } return ReportUtils.shouldReportBeInOptionList({ report, currentReportId: currentReportId ?? '', @@ -92,6 +103,7 @@ function getOrderedReportIDs( policies, excludeEmptyChats: true, doesReportHaveViolations, + includeSelfDM: true, }); }); @@ -104,7 +116,7 @@ function getOrderedReportIDs( } // The LHN is split into four distinct groups, and each group is sorted a little differently. The groups will ALWAYS be in this order: - // 1. Pinned/GBR - Always sorted by reportDisplayName + // 1. Pinned/GBR/RBR - Always sorted by reportDisplayName // 2. Drafts - Always sorted by reportDisplayName // 3. Non-archived reports and settled IOUs // - Sorted by lastVisibleActionCreated in default (most recent) view mode @@ -112,13 +124,15 @@ function getOrderedReportIDs( // 4. Archived reports // - Sorted by lastVisibleActionCreated in default (most recent) view mode // - Sorted by reportDisplayName in GSD (focus) view mode - const pinnedAndGBRReports: Report[] = []; + const pinnedAndBrickRoadReports: Report[] = []; const draftReports: Report[] = []; const nonArchivedReports: Report[] = []; const archivedReports: Report[] = []; if (currentPolicyID || policyMemberAccountIDs.length > 0) { - reportsToDisplay = reportsToDisplay.filter((report) => ReportUtils.doesReportBelongToWorkspace(report, policyMemberAccountIDs, currentPolicyID)); + reportsToDisplay = reportsToDisplay.filter( + (report) => report.reportID === currentReportId || ReportUtils.doesReportBelongToWorkspace(report, policyMemberAccountIDs, currentPolicyID), + ); } // There are a few properties that need to be calculated for the report which are used when sorting reports. reportsToDisplay.forEach((report) => { @@ -126,12 +140,14 @@ function getOrderedReportIDs( // However, this code needs to be very performant to handle thousands of reports, so in the interest of speed, we're just going to disable this lint rule and add // the reportDisplayName property to the report object directly. // eslint-disable-next-line no-param-reassign - report.displayName = ReportUtils.getReportName(report); + report.displayName = filterDisplayName(ReportUtils.getReportName(report)); + + const hasRBR = report.reportID in reportIDsWithErrors || reportIDsWithViolations.has(report.reportID); const isPinned = report.isPinned ?? false; const reportAction = ReportActionsUtils.getReportAction(report.parentReportID ?? '', report.parentReportActionID ?? ''); - if (isPinned || ReportUtils.requiresAttentionFromCurrentUser(report, reportAction)) { - pinnedAndGBRReports.push(report); + if (isPinned || hasRBR || ReportUtils.requiresAttentionFromCurrentUser(report, reportAction)) { + pinnedAndBrickRoadReports.push(report); } else if (report.hasDraft) { draftReports.push(report); } else if (ReportUtils.isArchivedRoom(report)) { @@ -142,7 +158,7 @@ function getOrderedReportIDs( }); // Sort each group of reports accordingly - pinnedAndGBRReports.sort((a, b) => (a?.displayName && b?.displayName ? localeCompare(a.displayName, b.displayName) : 0)); + pinnedAndBrickRoadReports.sort((a, b) => (a?.displayName && b?.displayName ? localeCompare(a.displayName, b.displayName) : 0)); draftReports.sort((a, b) => (a?.displayName && b?.displayName ? localeCompare(a.displayName, b.displayName) : 0)); if (isInDefaultMode) { @@ -160,7 +176,7 @@ function getOrderedReportIDs( // Now that we have all the reports grouped and sorted, they must be flattened into an array and only return the reportID. // The order the arrays are concatenated in matters and will determine the order that the groups are displayed in the sidebar. - const LHNReports = [...pinnedAndGBRReports, ...draftReports, ...nonArchivedReports, ...archivedReports].map((report) => report.reportID); + const LHNReports = [...pinnedAndBrickRoadReports, ...draftReports, ...nonArchivedReports, ...archivedReports].map((report) => report.reportID); return LHNReports; } @@ -169,19 +185,19 @@ function getOrderedReportIDs( */ function getOptionData({ report, - reportActions, personalDetails, preferredLocale, policy, parentReportAction, + reportErrors, hasViolations, }: { report: OnyxEntry; - reportActions: OnyxEntry; personalDetails: OnyxEntry; preferredLocale: DeepValueOf; policy: OnyxEntry | undefined; parentReportAction: OnyxEntry | undefined; + reportErrors: OnyxCommon.Errors | undefined; hasViolations: boolean; }): ReportUtils.OptionData | undefined { // When a user signs out, Onyx is cleared. Due to the lazy rendering with a virtual list, it's possible for @@ -194,7 +210,7 @@ function getOptionData({ const result: ReportUtils.OptionData = { text: '', alternateText: null, - allReportErrors: OptionsListUtils.getAllReportErrors(report, reportActions), + allReportErrors: reportErrors, brickRoadIndicator: null, tooltipText: null, subtitle: null, @@ -221,7 +237,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 +281,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 +291,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 +318,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/ValidationUtils.ts b/src/libs/ValidationUtils.ts index 669d10c4a1b8..3d5f23a84f74 100644 --- a/src/libs/ValidationUtils.ts +++ b/src/libs/ValidationUtils.ts @@ -82,7 +82,7 @@ function isValidPastDate(date: string | Date): boolean { * Used to validate a value that is "required". * @param value - field value */ -function isRequiredFulfilled(value?: FormValue): boolean { +function isRequiredFulfilled(value?: FormValue | number[] | string[] | Record): boolean { if (!value) { return false; } diff --git a/src/libs/actions/User.ts b/src/libs/actions/User.ts index 5d089ed6e393..a14e752b1015 100644 --- a/src/libs/actions/User.ts +++ b/src/libs/actions/User.ts @@ -33,7 +33,7 @@ import playSoundExcludingMobile from '@libs/Sound/playSoundExcludingMobile'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; -import type {BlockedFromConcierge, FrequentlyUsedEmoji} from '@src/types/onyx'; +import type {BlockedFromConcierge, FrequentlyUsedEmoji, Policy} from '@src/types/onyx'; import type Login from '@src/types/onyx/Login'; import type {OnyxServerUpdate} from '@src/types/onyx/OnyxUpdatesFromServer'; import type OnyxPersonalDetails from '@src/types/onyx/PersonalDetails'; @@ -775,7 +775,7 @@ function generateStatementPDF(period: string) { /** * Sets a contact method / secondary login as the user's "Default" contact method. */ -function setContactMethodAsDefault(newDefaultContactMethod: string) { +function setContactMethodAsDefault(newDefaultContactMethod: string, policies: OnyxCollection>) { const oldDefaultContactMethod = currentEmail; const optimisticData: OnyxUpdate[] = [ { @@ -868,6 +868,25 @@ function setContactMethodAsDefault(newDefaultContactMethod: string) { }, ]; + Object.values(policies ?? {}).forEach((policy) => { + if (policy?.ownerAccountID !== currentUserAccountID) { + return; + } + optimisticData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policy.id}`, + value: { + owner: newDefaultContactMethod, + }, + }); + failureData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policy.id}`, + value: { + owner: policy.owner, + }, + }); + }); const parameters: SetContactMethodAsDefaultParams = { partnerUserID: newDefaultContactMethod, }; 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/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/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/home/sidebar/SidebarLinks.js b/src/pages/home/sidebar/SidebarLinks.js index e872bbad008a..0a97f00c5002 100644 --- a/src/pages/home/sidebar/SidebarLinks.js +++ b/src/pages/home/sidebar/SidebarLinks.js @@ -43,7 +43,7 @@ const propTypes = { isActiveReport: PropTypes.func.isRequired, }; -function SidebarLinks({onLinkClick, insets, optionListItems, isLoading, priorityMode = CONST.PRIORITY_MODE.DEFAULT, isActiveReport, isCreateMenuOpen, activePolicy}) { +function SidebarLinks({onLinkClick, insets, optionListItems, isLoading, priorityMode = CONST.PRIORITY_MODE.DEFAULT, isActiveReport, isCreateMenuOpen, activePolicy, reportIDsWithErrors}) { const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); const modal = useRef({}); @@ -154,6 +154,7 @@ function SidebarLinks({onLinkClick, insets, optionListItems, isLoading, priority shouldDisableFocusOptions={isSmallScreenWidth} optionMode={viewMode} onFirstItemRendered={App.setSidebarLoaded} + reportIDsWithErrors={reportIDsWithErrors} /> {isLoading && optionListItems.length === 0 && ( diff --git a/src/pages/home/sidebar/SidebarLinksData.js b/src/pages/home/sidebar/SidebarLinksData.js index c4cc0713c596..3a1b17aa7fbd 100644 --- a/src/pages/home/sidebar/SidebarLinksData.js +++ b/src/pages/home/sidebar/SidebarLinksData.js @@ -1,6 +1,6 @@ import {deepEqual} from 'fast-equals'; import lodashGet from 'lodash/get'; -import lodashMap from 'lodash/map'; +import lodashMapValues from 'lodash/mapValues'; import PropTypes from 'prop-types'; import React, {useCallback, useEffect, useMemo, useRef} from 'react'; import {View} from 'react-native'; @@ -13,9 +13,11 @@ import withCurrentUserPersonalDetails, {withCurrentUserPersonalDetailsDefaultPro import withNavigationFocus from '@components/withNavigationFocus'; import useActiveWorkspace from '@hooks/useActiveWorkspace'; import useLocalize from '@hooks/useLocalize'; +import usePermissions from '@hooks/usePermissions'; import usePrevious from '@hooks/usePrevious'; import useThemeStyles from '@hooks/useThemeStyles'; import compose from '@libs/compose'; +import * as OptionsListUtils from '@libs/OptionsListUtils'; import {getPolicyMembersByIdWithoutCurrentUser} from '@libs/PolicyUtils'; import * as ReportUtils from '@libs/ReportUtils'; import SidebarUtils from '@libs/SidebarUtils'; @@ -35,20 +37,11 @@ const propTypes = { /** All report actions for all reports */ /** Object of report actions for this report */ - allReportActions: PropTypes.objectOf( - PropTypes.arrayOf( - PropTypes.shape({ - error: PropTypes.string, - message: PropTypes.arrayOf( - PropTypes.shape({ - moderationDecision: PropTypes.shape({ - decision: PropTypes.string, - }), - }), - ), - }), - ), - ), + // eslint-disable-next-line react/forbid-prop-types + allReportActions: PropTypes.object, + + // eslint-disable-next-line react/forbid-prop-types + allTransactions: PropTypes.object, /** Whether the reports are loading. When false it means they are ready to be used. */ isLoadingApp: PropTypes.bool, @@ -105,12 +98,14 @@ const defaultProps = { policyMembers: {}, transactionViolations: {}, allReportActions: {}, + allTransactions: {}, ...withCurrentUserPersonalDetailsDefaultProps, }; function SidebarLinksData({ isFocused, allReportActions, + allTransactions, betas, chatReports, currentReportID, @@ -128,12 +123,30 @@ function SidebarLinksData({ const {activeWorkspaceID} = useActiveWorkspace(); const {translate} = useLocalize(); const prevPriorityMode = usePrevious(priorityMode); + const {canUseViolations} = usePermissions(); const policyMemberAccountIDs = getPolicyMembersByIdWithoutCurrentUser(policyMembers, activeWorkspaceID, currentUserPersonalDetails.accountID); // eslint-disable-next-line react-hooks/exhaustive-deps useEffect(() => Policy.openWorkspace(activeWorkspaceID, policyMemberAccountIDs), [activeWorkspaceID]); + const reportIDsWithErrors = useMemo(() => { + const reportKeys = _.keys(chatReports); + return _.reduce( + reportKeys, + (errorsMap, reportKey) => { + const report = chatReports[reportKey]; + const allReportsActions = allReportActions[reportKey.replace(ONYXKEYS.COLLECTION.REPORT, ONYXKEYS.COLLECTION.REPORT_ACTIONS)]; + const errors = OptionsListUtils.getAllReportErrors(report, allReportsActions, allTransactions) || {}; + if (_.size(errors) === 0) { + return errorsMap; + } + return {...errorsMap, [reportKey.replace(ONYXKEYS.COLLECTION.REPORT, '')]: errors}; + }, + {}, + ); + }, [allReportActions, allTransactions, chatReports]); + const reportIDsRef = useRef(null); const isLoading = isLoadingApp; const optionListItems = useMemo(() => { @@ -147,6 +160,8 @@ function SidebarLinksData({ transactionViolations, activeWorkspaceID, policyMemberAccountIDs, + reportIDsWithErrors, + canUseViolations, ); if (deepEqual(reportIDsRef.current, reportIDs)) { @@ -160,7 +175,21 @@ function SidebarLinksData({ reportIDsRef.current = reportIDs; } return reportIDsRef.current || []; - }, [chatReports, betas, policies, priorityMode, allReportActions, transactionViolations, activeWorkspaceID, policyMemberAccountIDs, isLoading, network.isOffline, prevPriorityMode]); + }, [ + chatReports, + betas, + policies, + priorityMode, + allReportActions, + transactionViolations, + activeWorkspaceID, + policyMemberAccountIDs, + reportIDsWithErrors, + canUseViolations, + isLoading, + network.isOffline, + prevPriorityMode, + ]); // We need to make sure the current report is in the list of reports, but we do not want // to have to re-generate the list every time the currentReportID changes. To do that @@ -179,10 +208,25 @@ function SidebarLinksData({ transactionViolations, activeWorkspaceID, policyMemberAccountIDs, + reportIDsWithErrors, + canUseViolations, ); } return optionListItems; - }, [currentReportID, optionListItems, chatReports, betas, policies, priorityMode, allReportActions, transactionViolations, activeWorkspaceID, policyMemberAccountIDs]); + }, [ + currentReportID, + optionListItems, + chatReports, + betas, + policies, + priorityMode, + allReportActions, + transactionViolations, + activeWorkspaceID, + policyMemberAccountIDs, + reportIDsWithErrors, + canUseViolations, + ]); const currentReportIDRef = useRef(currentReportID); currentReportIDRef.current = currentReportID; @@ -204,6 +248,7 @@ function SidebarLinksData({ isLoading={isLoading} optionListItems={optionListItemsWithCurrentReport} activeWorkspaceID={activeWorkspaceID} + reportIDsWithErrors={reportIDsWithErrors} /> ); @@ -227,6 +272,7 @@ const chatReportSelector = (report) => isPinned: report.isPinned, isHidden: report.isHidden, notificationPreference: report.notificationPreference, + errors: report.errors, errorFields: { addWorkspaceRoom: report.errorFields && report.errorFields.addWorkspaceRoom, }, @@ -248,6 +294,9 @@ const chatReportSelector = (report) => reportName: report.reportName, policyName: report.policyName, oldPolicyName: report.oldPolicyName, + isPolicyExpenseChat: report.isPolicyExpenseChat, + isOwnPolicyExpenseChat: report.isOwnPolicyExpenseChat, + isCancelledIOU: report.isCancelledIOU, // Other less obvious properites considered for sorting: ownerAccountID: report.ownerAccountID, currency: report.currency, @@ -265,7 +314,7 @@ const chatReportSelector = (report) => */ const reportActionsSelector = (reportActions) => reportActions && - lodashMap(reportActions, (reportAction) => { + lodashMapValues(reportActions, (reportAction) => { const {reportActionID, parentReportActionID, actionName, errors = [], originalMessage} = reportAction; const decision = lodashGet(reportAction, 'message[0].moderationDecision.decision'); @@ -294,6 +343,24 @@ const policySelector = (policy) => avatar: policy.avatar, }; +/** + * @param {Object} [transaction] + * @returns {Object|undefined} + */ +const transactionSelector = (transaction) => + transaction && { + reportID: transaction.reportID, + iouRequestType: transaction.iouRequestType, + comment: transaction.comment, + receipt: transaction.receipt, + merchant: transaction.merchant, + modifiedMerchant: transaction.modifiedMerchant, + amount: transaction.amount, + modifiedAmount: transaction.modifiedAmount, + created: transaction.created, + modifiedCreated: transaction.modifiedCreated, + }; + export default compose( withCurrentReportID, withCurrentUserPersonalDetails, @@ -321,6 +388,11 @@ export default compose( selector: reportActionsSelector, initialValue: {}, }, + allTransactions: { + key: ONYXKEYS.COLLECTION.TRANSACTION, + selector: transactionSelector, + initialValue: {}, + }, policies: { key: ONYXKEYS.COLLECTION.POLICY, selector: policySelector, 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/Profile/Contacts/ContactMethodDetailsPage.tsx b/src/pages/settings/Profile/Contacts/ContactMethodDetailsPage.tsx index 7de22da728dd..18589beb6353 100644 --- a/src/pages/settings/Profile/Contacts/ContactMethodDetailsPage.tsx +++ b/src/pages/settings/Profile/Contacts/ContactMethodDetailsPage.tsx @@ -28,7 +28,7 @@ import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import type SCREENS from '@src/SCREENS'; -import type {LoginList, SecurityGroup, Session as TSession} from '@src/types/onyx'; +import type {LoginList, Policy, SecurityGroup, Session as TSession} from '@src/types/onyx'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; import ValidateCodeForm from './ValidateCodeForm'; import type {ValidateCodeFormHandle} from './ValidateCodeForm/BaseValidateCodeForm'; @@ -48,11 +48,14 @@ type ContactMethodDetailsPageOnyxProps = { /** Indicated whether the report data is loading */ isLoadingReportData: OnyxEntry; + + /** The list of this user's policies */ + policies: OnyxCollection>; }; type ContactMethodDetailsPageProps = ContactMethodDetailsPageOnyxProps & StackScreenProps; -function ContactMethodDetailsPage({loginList, session, myDomainSecurityGroups, securityGroups, isLoadingReportData = true, route}: ContactMethodDetailsPageProps) { +function ContactMethodDetailsPage({loginList, session, myDomainSecurityGroups, securityGroups, isLoadingReportData = true, route, policies}: ContactMethodDetailsPageProps) { const {formatPhoneNumber, translate} = useLocalize(); const theme = useTheme(); const themeStyles = useThemeStyles(); @@ -88,8 +91,8 @@ function ContactMethodDetailsPage({loginList, session, myDomainSecurityGroups, s * Attempt to set this contact method as user's "Default contact method" */ const setAsDefault = useCallback(() => { - User.setContactMethodAsDefault(contactMethod); - }, [contactMethod]); + User.setContactMethodAsDefault(contactMethod, policies); + }, [contactMethod, policies]); /** * Checks if the user is allowed to change their default contact method. This should only be allowed if: @@ -302,4 +305,12 @@ export default withOnyx ({ + id: data?.id ?? '', + ownerAccountID: data?.ownerAccountID, + owner: data?.owner ?? '', + }), + }, })(ContactMethodDetailsPage); 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/workspace/WorkspaceMembersPage.tsx b/src/pages/workspace/WorkspaceMembersPage.tsx index 718f2b6d9ad2..b43659a776d9 100644 --- a/src/pages/workspace/WorkspaceMembersPage.tsx +++ b/src/pages/workspace/WorkspaceMembersPage.tsx @@ -113,9 +113,8 @@ function WorkspaceMembersPage({policyMembers, personalDetails, route, policy, se */ const validateSelection = useCallback(() => { const newErrors: Errors = {}; - const ownerAccountID = PersonalDetailsUtils.getAccountIDsByLogins(policy?.owner ? [policy.owner] : [])[0]; selectedEmployees.forEach((member) => { - if (member !== ownerAccountID && member !== session?.accountID) { + if (member !== policy?.ownerAccountID && member !== session?.accountID) { return; } newErrors[member] = translate('workspace.people.error.cannotRemove'); @@ -326,7 +325,7 @@ function WorkspaceMembersPage({policyMembers, personalDetails, route, policy, se isSelected, isDisabled: accountID === session?.accountID || - details.login === policy?.owner || + accountID === policy?.ownerAccountID || policyMember.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE || !isEmptyObject(policyMember.errors), text: formatPhoneNumber(PersonalDetailsUtils.getDisplayNameOrDefault(details)), 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/config.js b/tests/e2e/config.js index d51119e77430..62fecb980751 100644 --- a/tests/e2e/config.js +++ b/tests/e2e/config.js @@ -72,14 +72,15 @@ export default { [TEST_NAMES.OpenSearchPage]: { name: TEST_NAMES.OpenSearchPage, }, - [TEST_NAMES.ReportTyping]: { - name: TEST_NAMES.ReportTyping, - reportScreen: { - autoFocus: true, - }, - // Crowded Policy (Do Not Delete) Report, has a input bar available: - reportID: '8268282951170052', - }, + // TODO: Fix text and enable again + // [TEST_NAMES.ReportTyping]: { + // name: TEST_NAMES.ReportTyping, + // reportScreen: { + // autoFocus: true, + // }, + // // Crowded Policy (Do Not Delete) Report, has a input bar available: + // reportID: '8268282951170052', + // }, [TEST_NAMES.ChatOpening]: { name: TEST_NAMES.ChatOpening, // #announce Chat with many messages 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/e2e/nativeCommands/NativeCommandsAction.js b/tests/e2e/nativeCommands/NativeCommandsAction.ts similarity index 62% rename from tests/e2e/nativeCommands/NativeCommandsAction.js rename to tests/e2e/nativeCommands/NativeCommandsAction.ts index 24ceefb5acb7..17187ca66f1c 100644 --- a/tests/e2e/nativeCommands/NativeCommandsAction.js +++ b/tests/e2e/nativeCommands/NativeCommandsAction.ts @@ -1,17 +1,19 @@ +import type {NativeCommand} from '@libs/E2E/client'; + const NativeCommandsAction = { scroll: 'scroll', type: 'type', backspace: 'backspace', -}; +} as const; -const makeTypeTextCommand = (text) => ({ +const makeTypeTextCommand = (text: string): NativeCommand => ({ actionName: NativeCommandsAction.type, payload: { text, }, }); -const makeBackspaceCommand = () => ({ +const makeBackspaceCommand = (): NativeCommand => ({ actionName: NativeCommandsAction.backspace, }); diff --git a/tests/e2e/utils/sleep.js b/tests/e2e/utils/sleep.ts similarity index 54% rename from tests/e2e/utils/sleep.js rename to tests/e2e/utils/sleep.ts index 6d37ca3cd510..c3f7142a898f 100644 --- a/tests/e2e/utils/sleep.js +++ b/tests/e2e/utils/sleep.ts @@ -1,5 +1,7 @@ -export default function sleep(ms) { +function sleep(ms: number): Promise { return new Promise((resolve) => { setTimeout(resolve, ms); }); } + +export default sleep; diff --git a/tests/perf-test/SidebarUtils.perf-test.ts b/tests/perf-test/SidebarUtils.perf-test.ts index 3aa65331b9c2..2b2bdbc6b57a 100644 --- a/tests/perf-test/SidebarUtils.perf-test.ts +++ b/tests/perf-test/SidebarUtils.perf-test.ts @@ -1,13 +1,15 @@ import type {OnyxCollection} from 'react-native-onyx'; import Onyx from 'react-native-onyx'; import {measureFunction} from 'reassure'; +import * as OptionsListUtils from '@libs/OptionsListUtils'; import SidebarUtils from '@libs/SidebarUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import type {PersonalDetails, TransactionViolation} from '@src/types/onyx'; +import type {PersonalDetails, ReportActions, TransactionViolation} from '@src/types/onyx'; import type Policy from '@src/types/onyx/Policy'; import type Report from '@src/types/onyx/Report'; import type ReportAction from '@src/types/onyx/ReportAction'; +import {isEmptyObject} from '@src/types/utils/EmptyObject'; import createCollection from '../utils/collections/createCollection'; import createPersonalDetails from '../utils/collections/personalDetails'; import createRandomPolicy from '../utils/collections/policies'; @@ -61,11 +63,11 @@ describe('SidebarUtils', () => { await measureFunction(() => SidebarUtils.getOptionData({ report, - reportActions, personalDetails, preferredLocale, policy, parentReportAction, + reportErrors: undefined, hasViolations: false, }), ); @@ -85,8 +87,8 @@ describe('SidebarUtils', () => { const allReportActions = Object.fromEntries( Object.keys(reportActions).map((key) => [ key, - [ - { + { + [reportActions[key].reportActionID]: { errors: reportActions[key].errors ?? [], message: [ { @@ -96,11 +98,35 @@ describe('SidebarUtils', () => { }, ], }, - ], + }, ]), - ) as unknown as OnyxCollection; + ) as unknown as OnyxCollection; + + const reportKeys = Object.keys(allReports); + const reportIDsWithErrors = reportKeys.reduce((errorsMap, reportKey) => { + const report = allReports[reportKey]; + const allReportsActions = allReportActions?.[reportKey.replace('report_', 'reportActions_')] ?? null; + const errors = OptionsListUtils.getAllReportErrors(report, allReportsActions) || {}; + if (isEmptyObject(errors)) { + return errorsMap; + } + return {...errorsMap, [reportKey.replace('report_', '')]: errors}; + }, {}); await waitForBatchedUpdates(); - await measureFunction(() => SidebarUtils.getOrderedReportIDs(currentReportId, allReports, betas, policies, CONST.PRIORITY_MODE.DEFAULT, allReportActions, transactionViolations)); + await measureFunction(() => + SidebarUtils.getOrderedReportIDs( + currentReportId, + allReports, + betas, + policies, + CONST.PRIORITY_MODE.DEFAULT, + allReportActions, + transactionViolations, + undefined, + undefined, + reportIDsWithErrors, + ), + ); }); }); diff --git a/tests/unit/ValidationUtilsTest.js b/tests/unit/ValidationUtilsTest.ts similarity index 94% rename from tests/unit/ValidationUtilsTest.js rename to tests/unit/ValidationUtilsTest.ts index 43e389600d62..a14d71136b22 100644 --- a/tests/unit/ValidationUtilsTest.js +++ b/tests/unit/ValidationUtilsTest.ts @@ -1,6 +1,6 @@ import {addDays, format, startOfDay, subYears} from 'date-fns'; -import CONST from '../../src/CONST'; -import * as ValidationUtils from '../../src/libs/ValidationUtils'; +import CONST from '@src/CONST'; +import * as ValidationUtils from '@src/libs/ValidationUtils'; describe('ValidationUtils', () => { describe('isValidDate', () => { @@ -93,7 +93,7 @@ describe('ValidationUtils', () => { }); test('Should return false for an empty array value', () => { - const emptyArrayValue = []; + const emptyArrayValue: string[] = []; const isFulfilled = ValidationUtils.isRequiredFulfilled(emptyArrayValue); expect(isFulfilled).toBe(false); }); @@ -178,19 +178,19 @@ describe('ValidationUtils', () => { describe('getAgeRequirementError', () => { test('Should return an empty string for a date within the specified range', () => { - const validDate = format(subYears(new Date(), 30), CONST.DATE.FNS_FORMAT_STRING); // Date of birth 30 years ago + const validDate: string = format(subYears(new Date(), 30), CONST.DATE.FNS_FORMAT_STRING); // Date of birth 30 years ago const error = ValidationUtils.getAgeRequirementError(validDate, 18, 150); expect(error).toBe(''); }); test('Should return an error message for a date before the minimum age requirement', () => { - const invalidDate = format(subYears(new Date(), 17), CONST.DATE.FNS_FORMAT_STRING); // Date of birth 17 years ago + const invalidDate: string = format(subYears(new Date(), 17), CONST.DATE.FNS_FORMAT_STRING); // Date of birth 17 years ago const error = ValidationUtils.getAgeRequirementError(invalidDate, 18, 150); expect(error).toEqual(['privatePersonalDetails.error.dateShouldBeBefore', {dateString: format(startOfDay(subYears(new Date(), 18)), CONST.DATE.FNS_FORMAT_STRING)}]); }); test('Should return an error message for a date after the maximum age requirement', () => { - const invalidDate = format(subYears(new Date(), 160), CONST.DATE.FNS_FORMAT_STRING); // Date of birth 160 years ago + const invalidDate: string = format(subYears(new Date(), 160), CONST.DATE.FNS_FORMAT_STRING); // Date of birth 160 years ago const error = ValidationUtils.getAgeRequirementError(invalidDate, 18, 150); expect(error).toEqual(['privatePersonalDetails.error.dateShouldBeAfter', {dateString: format(startOfDay(subYears(new Date(), 150)), CONST.DATE.FNS_FORMAT_STRING)}]); }); @@ -305,12 +305,6 @@ describe('ValidationUtils', () => { expect(ValidationUtils.isValidAccountRoute(123123)).toBe(true); expect(ValidationUtils.isValidAccountRoute(5612)).toBe(true); }); - - test('Invalid account route', () => { - expect(ValidationUtils.isValidAccountRoute(undefined)).toBe(false); - expect(ValidationUtils.isValidAccountRoute(0)).toBe(false); - expect(ValidationUtils.isValidAccountRoute('123aaa')).toBe(false); - }); }); describe('ValidatePersonName', () => { diff --git a/tests/utils/waitForBatchedUpdates.js b/tests/utils/waitForBatchedUpdates.ts similarity index 94% rename from tests/utils/waitForBatchedUpdates.js rename to tests/utils/waitForBatchedUpdates.ts index c9d7a99fa1fb..8455371093d8 100644 --- a/tests/utils/waitForBatchedUpdates.js +++ b/tests/utils/waitForBatchedUpdates.ts @@ -10,10 +10,8 @@ import getIsUsingFakeTimers from './getIsUsingFakeTimers'; * than to do * ❌ Onyx.merge(...) * waitForBatchedUpdates().then(...) - * - * @returns {Promise} */ -export default () => +const waitForBatchedUpdates = (): Promise => new Promise((outerResolve) => { // We first need to exhaust the microtask queue, before we schedule the next task in the macrotask queue (setTimeout). // This is because we need to wait for all async onyx operations to finish, as they might schedule other macrotasks, @@ -38,3 +36,5 @@ export default () => setTimeout(outerResolve, 0); }); }); + +export default waitForBatchedUpdates; diff --git a/tests/utils/waitForNetworkPromises.js b/tests/utils/waitForNetworkPromises.ts similarity index 74% rename from tests/utils/waitForNetworkPromises.js rename to tests/utils/waitForNetworkPromises.ts index a60061d597e3..8c35514b870e 100644 --- a/tests/utils/waitForNetworkPromises.js +++ b/tests/utils/waitForNetworkPromises.ts @@ -8,7 +8,7 @@ import waitForBatchedUpdates from './waitForBatchedUpdates'; * than to do * ❌ Onyx.merge(...) * waitForBatchedUpdates().then(...) - * - * @returns {Promise} */ -export default () => waitForBatchedUpdates().then(waitForBatchedUpdates); +const waitForNetworkPromises = (): Promise => waitForBatchedUpdates().then(waitForBatchedUpdates); + +export default waitForNetworkPromises; diff --git a/tests/utils/wrapOnyxWithWaitForBatchedUpdates.js b/tests/utils/wrapOnyxWithWaitForBatchedUpdates.ts similarity index 84% rename from tests/utils/wrapOnyxWithWaitForBatchedUpdates.js rename to tests/utils/wrapOnyxWithWaitForBatchedUpdates.ts index e5b6e6bfdfcf..862f3281b2b8 100644 --- a/tests/utils/wrapOnyxWithWaitForBatchedUpdates.js +++ b/tests/utils/wrapOnyxWithWaitForBatchedUpdates.ts @@ -1,3 +1,4 @@ +import type Onyx from 'react-native-onyx'; import waitForBatchedUpdates from './waitForBatchedUpdates'; /** @@ -7,10 +8,8 @@ import waitForBatchedUpdates from './waitForBatchedUpdates'; * are rendered with the onyx data. * This is a convinience function, which wraps the onyxInstance's * functions, to for the promises to resolve. - * - * @param {Object} onyxInstance */ -export default function wrapOnyxWithWaitForBatchedUpdates(onyxInstance) { +function wrapOnyxWithWaitForBatchedUpdates(onyxInstance: typeof Onyx) { const multiSetImpl = onyxInstance.multiSet; // eslint-disable-next-line no-param-reassign onyxInstance.multiSet = (...args) => multiSetImpl(...args).then((result) => waitForBatchedUpdates().then(() => result)); @@ -18,3 +17,5 @@ export default function wrapOnyxWithWaitForBatchedUpdates(onyxInstance) { // eslint-disable-next-line no-param-reassign onyxInstance.merge = (...args) => mergeImpl(...args).then((result) => waitForBatchedUpdates().then(() => result)); } + +export default wrapOnyxWithWaitForBatchedUpdates;