diff --git a/.eslintignore b/.eslintignore index d3358a02fe4b..5338df11c520 100644 --- a/.eslintignore +++ b/.eslintignore @@ -1,5 +1,11 @@ -**/node_modules/* -**/dist/* -android/**/build/* -.github/actions/**/index.js" +!.storybook +!.github +.github/actions/**/index.js +*.config.js +**/.eslintrc.js +**/node_modules/** +**/dist/** +android/**/build/** docs/vendor/** +docs/assets/** +web/gtm.js diff --git a/.eslintrc.js b/.eslintrc.js index c8a842fa4650..1f23ae22ca7e 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -4,9 +4,9 @@ const restrictedImportPaths = [ importNames: ['useWindowDimensions', 'StatusBar', 'TouchableOpacity', 'TouchableWithoutFeedback', 'TouchableNativeFeedback', 'TouchableHighlight', 'Pressable', 'Text', 'ScrollView'], message: [ '', - "For 'useWindowDimensions', please use 'src/hooks/useWindowDimensions' instead.", - "For 'TouchableOpacity', 'TouchableWithoutFeedback', 'TouchableNativeFeedback', 'TouchableHighlight', 'Pressable', please use 'PressableWithFeedback' and/or 'PressableWithoutFeedback' from 'src/components/Pressable' instead.", - "For 'StatusBar', please use 'src/libs/StatusBar' instead.", + "For 'useWindowDimensions', please use '@src/hooks/useWindowDimensions' instead.", + "For 'TouchableOpacity', 'TouchableWithoutFeedback', 'TouchableNativeFeedback', 'TouchableHighlight', 'Pressable', please use 'PressableWithFeedback' and/or 'PressableWithoutFeedback' from '@components/Pressable' instead.", + "For 'StatusBar', please use '@src/libs/StatusBar' instead.", "For 'Text', please use '@components/Text' instead.", "For 'ScrollView', please use '@components/ScrollView' instead.", ].join('\n'), @@ -14,7 +14,7 @@ const restrictedImportPaths = [ { name: 'react-native-gesture-handler', importNames: ['TouchableOpacity', 'TouchableWithoutFeedback', 'TouchableNativeFeedback', 'TouchableHighlight'], - message: "Please use 'PressableWithFeedback' and/or 'PressableWithoutFeedback' from 'src/components/Pressable' instead.", + message: "Please use 'PressableWithFeedback' and/or 'PressableWithoutFeedback' from '@components/Pressable' instead.", }, { name: 'awesome-phonenumber', @@ -24,7 +24,7 @@ const restrictedImportPaths = [ { name: 'react-native-safe-area-context', importNames: ['useSafeAreaInsets', 'SafeAreaConsumer', 'SafeAreaInsetsContext'], - message: "Please use 'useSafeAreaInsets' from 'src/hooks/useSafeAreaInset' and/or 'SafeAreaConsumer' from 'src/components/SafeAreaConsumer' instead.", + message: "Please use 'useSafeAreaInsets' from '@src/hooks/useSafeAreaInset' and/or 'SafeAreaConsumer' from '@components/SafeAreaConsumer' instead.", }, { name: 'react', @@ -34,18 +34,18 @@ const restrictedImportPaths = [ { name: '@styles/index', importNames: ['default', 'defaultStyles'], - message: 'Do not import styles directly. Please use the `useThemeStyles` hook or `withThemeStyles` HOC instead.', + message: 'Do not import styles directly. Please use the `useThemeStyles` hook instead.', }, { name: '@styles/utils', importNames: ['default', 'DefaultStyleUtils'], - message: 'Do not import StyleUtils directly. Please use the `useStyleUtils` hook or `withStyleUtils` HOC instead.', + message: 'Do not import StyleUtils directly. Please use the `useStyleUtils` hook instead.', }, { name: '@styles/theme', importNames: ['default', 'defaultTheme'], - message: 'Do not import themes directly. Please use the `useTheme` hook or `withTheme` HOC instead.', + message: 'Do not import themes directly. Please use the `useTheme` hook instead.', }, { name: '@styles/theme/illustrations', @@ -60,15 +60,15 @@ const restrictedImportPaths = [ const restrictedImportPatterns = [ { group: ['**/assets/animations/**/*.json'], - message: "Do not import animations directly. Please use the 'src/components/LottieAnimations' import instead.", + message: "Do not import animations directly. Please use the '@components/LottieAnimations' import instead.", }, { group: ['@styles/theme/themes/**'], - message: 'Do not import themes directly. Please use the `useTheme` hook or `withTheme` HOC instead.', + message: 'Do not import themes directly. Please use the `useTheme` hook instead.', }, { group: ['@styles/utils/**', '!@styles/utils/FontUtils', '!@styles/utils/types'], - message: 'Do not import style util functions directly. Please use the `useStyleUtils` hook or `withStyleUtils` HOC instead.', + message: 'Do not import style util functions directly. Please use the `useStyleUtils` hook instead.', }, { group: ['@styles/theme/illustrations/themes/**'], @@ -77,218 +77,175 @@ const restrictedImportPatterns = [ ]; module.exports = { - extends: ['expensify', 'plugin:storybook/recommended', 'plugin:react-native-a11y/basic', 'plugin:@dword-design/import-alias/recommended', 'prettier'], - plugins: ['react-native-a11y', 'testing-library'], - parser: 'babel-eslint', - ignorePatterns: ['!.*', 'src/vendor', '.github/actions/**/index.js', 'desktop/dist/*.js', 'dist/*.js', 'node_modules/.bin/**', 'node_modules/.cache/**', '.git/**'], + extends: [ + 'expensify', + 'airbnb-typescript', + 'plugin:storybook/recommended', + 'plugin:react-native-a11y/basic', + 'plugin:@dword-design/import-alias/recommended', + 'plugin:@typescript-eslint/recommended-type-checked', + 'plugin:@typescript-eslint/stylistic-type-checked', + 'plugin:you-dont-need-lodash-underscore/all', + 'prettier', + ], + plugins: ['@typescript-eslint', 'jsdoc', 'you-dont-need-lodash-underscore', 'react-native-a11y', 'react', 'testing-library'], + parser: '@typescript-eslint/parser', + parserOptions: { + project: './tsconfig.json', + }, env: { jest: true, }, globals: { __DEV__: 'readonly', }, - overrides: [ - { - files: ['*.js', '*.jsx', '*.ts', '*.tsx'], - plugins: ['react'], - rules: { - 'prefer-regex-literals': 'off', - 'rulesdir/no-multiple-onyx-in-file': 'off', - 'react-native-a11y/has-accessibility-hint': ['off'], - 'react/jsx-no-constructed-context-values': 'error', - 'react-native-a11y/has-valid-accessibility-descriptors': [ - 'error', - { - touchables: ['PressableWithoutFeedback', 'PressableWithFeedback'], - }, - ], - '@dword-design/import-alias/prefer-alias': [ - 'warn', - { - alias: { - '@assets': './assets', - '@components': './src/components', - '@hooks': './src/hooks', - // This is needed up here, if not @libs/actions would take the priority - '@userActions': './src/libs/actions', - '@libs': './src/libs', - '@navigation': './src/libs/Navigation', - '@pages': './src/pages', - '@styles': './src/styles', - // This path is provide alias for files like `ONYXKEYS` and `CONST`. - '@src': './src', - '@desktop': './desktop', - '@github': './.github', - }, - }, - ], - 'rulesdir/avoid-anonymous-functions': 'off', + rules: { + '@typescript-eslint/no-unsafe-argument': 'off', + '@typescript-eslint/no-unsafe-call': 'off', + '@typescript-eslint/no-unsafe-member-access': 'off', + '@typescript-eslint/no-unsafe-assignment': 'off', + + // TypeScript specific rules + '@typescript-eslint/prefer-enum-initializers': 'error', + '@typescript-eslint/no-var-requires': 'off', + '@typescript-eslint/no-non-null-assertion': 'error', + '@typescript-eslint/switch-exhaustiveness-check': 'error', + '@typescript-eslint/consistent-type-definitions': ['error', 'type'], + '@typescript-eslint/no-floating-promises': 'off', + '@typescript-eslint/no-import-type-side-effects': 'error', + '@typescript-eslint/array-type': ['error', {default: 'array-simple'}], + '@typescript-eslint/naming-convention': [ + 'error', + { + selector: ['variable', 'property'], + format: ['camelCase', 'UPPER_CASE', 'PascalCase'], }, - }, - // This helps disable the `prefer-alias` rule to be enabled for specific directories - { - files: ['tests/**/*.js', 'tests/**/*.ts', 'tests/**/*.jsx', 'assets/**/*.js', '.storybook/**/*.js'], - rules: {'@dword-design/import-alias/prefer-alias': ['off']}, - }, - { - files: ['tests/**/*.js', 'tests/**/*.ts', 'tests/**/*.jsx', 'tests/**/*.tsx'], - extends: ['plugin:testing-library/react'], - rules: { - 'testing-library/await-async-queries': 'error', - 'testing-library/await-async-utils': 'error', - 'testing-library/no-debugging-utils': 'error', - 'testing-library/no-manual-cleanup': 'error', - 'testing-library/no-unnecessary-act': 'error', - 'testing-library/prefer-find-by': 'error', - 'testing-library/prefer-presence-queries': 'error', - 'testing-library/prefer-screen-queries': 'error', + { + selector: 'function', + format: ['camelCase', 'PascalCase'], }, - }, - { - files: ['*.js', '*.jsx'], - settings: { - 'import/resolver': { - node: { - extensions: ['.js', '.website.js', '.desktop.js', '.native.js', '.ios.js', '.android.js', '.config.js', '.ts', '.tsx'], - }, + { + selector: ['typeLike', 'enumMember'], + format: ['PascalCase'], + }, + { + selector: ['parameter', 'method'], + format: ['camelCase', 'PascalCase'], + leadingUnderscore: 'allow', + }, + ], + '@typescript-eslint/ban-types': [ + 'error', + { + types: { + object: "Use 'Record' instead.", }, + extendDefaults: true, }, - rules: { - 'import/extensions': [ - 'error', - 'ignorePackages', - { - js: 'never', - jsx: 'never', - ts: 'never', - tsx: 'never', - }, - ], - 'no-restricted-imports': [ - 'error', - { - paths: restrictedImportPaths, - patterns: restrictedImportPatterns, - }, - ], - curly: 'error', - 'react/display-name': 'error', + ], + '@typescript-eslint/consistent-type-imports': [ + 'error', + { + prefer: 'type-imports', + fixStyle: 'separate-type-imports', }, - }, - { - files: ['*.ts', '*.tsx'], - extends: [ - 'airbnb-typescript', - 'plugin:@typescript-eslint/recommended-type-checked', - 'plugin:@typescript-eslint/stylistic-type-checked', - 'plugin:you-dont-need-lodash-underscore/all', - 'prettier', - ], - plugins: ['@typescript-eslint', 'jsdoc', 'you-dont-need-lodash-underscore'], - parser: '@typescript-eslint/parser', - parserOptions: { - project: './tsconfig.json', + ], + '@typescript-eslint/consistent-type-exports': [ + 'error', + { + fixMixedExportsWithInlineTypeSpecifier: false, }, - rules: { - // TODO: Remove the following rules after TypeScript migration is complete. - '@typescript-eslint/no-unsafe-argument': 'off', - '@typescript-eslint/no-unsafe-call': 'off', - '@typescript-eslint/no-unsafe-member-access': 'off', - '@typescript-eslint/no-unsafe-assignment': 'off', + ], + + // ESLint core rules + 'es/no-nullish-coalescing-operators': 'off', + 'es/no-optional-chaining': 'off', + + // Import specific rules + 'import/consistent-type-specifier-style': ['error', 'prefer-top-level'], + 'import/no-extraneous-dependencies': 'off', + + // Rulesdir specific rules + 'rulesdir/no-default-props': 'error', + 'rulesdir/no-multiple-onyx-in-file': 'off', + 'rulesdir/prefer-underscore-method': 'off', + 'rulesdir/prefer-import-module-contents': 'off', - '@typescript-eslint/naming-convention': [ - 'error', - { - selector: ['variable', 'property'], - format: ['camelCase', 'UPPER_CASE', 'PascalCase'], - }, - { - selector: 'function', - format: ['camelCase', 'PascalCase'], - }, - { - selector: ['typeLike', 'enumMember'], - format: ['PascalCase'], - }, - { - selector: ['parameter', 'method'], - format: ['camelCase', 'PascalCase'], - leadingUnderscore: 'allow', - }, - ], - '@typescript-eslint/ban-types': [ - 'error', - { - types: { - object: "Use 'Record' instead.", - }, - extendDefaults: true, - }, - ], - '@typescript-eslint/array-type': ['error', {default: 'array-simple'}], - '@typescript-eslint/prefer-enum-initializers': 'error', - '@typescript-eslint/no-var-requires': 'off', - '@typescript-eslint/no-non-null-assertion': 'error', - '@typescript-eslint/switch-exhaustiveness-check': 'error', - '@typescript-eslint/consistent-type-definitions': ['error', 'type'], - '@typescript-eslint/no-floating-promises': 'off', - '@typescript-eslint/consistent-type-imports': [ - 'error', - { - prefer: 'type-imports', - fixStyle: 'separate-type-imports', - }, - ], - '@typescript-eslint/no-import-type-side-effects': 'error', - '@typescript-eslint/consistent-type-exports': [ - 'error', - { - fixMixedExportsWithInlineTypeSpecifier: false, - }, - ], - 'import/consistent-type-specifier-style': ['error', 'prefer-top-level'], - 'es/no-nullish-coalescing-operators': 'off', - 'es/no-optional-chaining': 'off', - 'valid-jsdoc': 'off', - 'jsdoc/no-types': 'error', - 'rulesdir/no-default-props': 'error', - 'import/no-extraneous-dependencies': 'off', - 'rulesdir/prefer-underscore-method': 'off', - 'rulesdir/prefer-import-module-contents': 'off', - 'react/require-default-props': 'off', - 'react/prop-types': 'off', - 'no-restricted-syntax': [ - 'error', - { - selector: 'TSEnumDeclaration', - message: "Please don't declare enums, use union types instead.", - }, - ], - 'no-restricted-properties': [ - 'error', - { - object: 'Image', - property: 'getSize', - message: 'Usage of Image.getImage is restricted. Please use the `react-native-image-size`.', - }, - ], - 'no-restricted-imports': [ - 'error', - { - paths: restrictedImportPaths, - patterns: restrictedImportPatterns, - }, - ], - curly: 'error', - 'you-dont-need-lodash-underscore/throttle': 'off', + // React and React Native specific rules + 'react-native-a11y/has-accessibility-hint': ['off'], + 'react/require-default-props': 'off', + 'react/prop-types': 'off', + 'react/jsx-no-constructed-context-values': 'error', + 'react-native-a11y/has-valid-accessibility-descriptors': [ + 'error', + { + touchables: ['PressableWithoutFeedback', 'PressableWithFeedback'], }, - }, + ], + + // Disallow usage of certain functions and imports + 'no-restricted-syntax': [ + 'error', + { + selector: 'TSEnumDeclaration', + message: "Please don't declare enums, use union types instead.", + }, + ], + 'no-restricted-properties': [ + 'error', + { + object: 'Image', + property: 'getSize', + message: 'Usage of Image.getImage is restricted. Please use the `react-native-image-size`.', + }, + ], + 'no-restricted-imports': [ + 'error', + { + paths: restrictedImportPaths, + patterns: restrictedImportPatterns, + }, + ], + + // Other rules + curly: 'error', + 'you-dont-need-lodash-underscore/throttle': 'off', + 'prefer-regex-literals': 'off', + 'valid-jsdoc': 'off', + 'jsdoc/no-types': 'error', + '@dword-design/import-alias/prefer-alias': [ + 'warn', + { + alias: { + '@assets': './assets', + '@components': './src/components', + '@hooks': './src/hooks', + // This is needed up here, if not @libs/actions would take the priority + '@userActions': './src/libs/actions', + '@libs': './src/libs', + '@navigation': './src/libs/Navigation', + '@pages': './src/pages', + '@styles': './src/styles', + // This path is provide alias for files like `ONYXKEYS` and `CONST`. + '@src': './src', + '@desktop': './desktop', + '@github': './.github', + }, + }, + ], + }, + + // Remove once no JS files are left + overrides: [ { - files: ['workflow_tests/**/*.{js,jsx,ts,tsx}', 'tests/**/*.{js,jsx,ts,tsx}', '.github/**/*.{js,jsx,ts,tsx}'], + files: ['*.js', '*.jsx'], rules: { - '@lwc/lwc/no-async-await': 'off', - 'no-await-in-loop': 'off', - 'no-restricted-syntax': ['error', 'ForInStatement', 'LabeledStatement', 'WithStatement'], + '@typescript-eslint/prefer-nullish-coalescing': 'off', + '@typescript-eslint/no-unsafe-return': 'off', + '@typescript-eslint/unbound-method': 'off', + 'jsdoc/no-types': 'off', + 'react/jsx-filename-extension': 'off', + 'rulesdir/no-default-props': 'off', }, }, { diff --git a/.github/.eslintrc.js b/.github/.eslintrc.js index e769944cd1a9..d6d39822b737 100644 --- a/.github/.eslintrc.js +++ b/.github/.eslintrc.js @@ -1,6 +1,10 @@ -// For all these Node.js scripts, we do not want to disable `console` statements module.exports = { rules: { + // For all these Node.js scripts, we do not want to disable `console` statements 'no-console': 'off', + + '@lwc/lwc/no-async-await': 'off', + 'no-await-in-loop': 'off', + 'no-restricted-syntax': ['error', 'ForInStatement', 'LabeledStatement', 'WithStatement'], }, }; diff --git a/.github/actions/javascript/awaitStagingDeploys/awaitStagingDeploys.ts b/.github/actions/javascript/awaitStagingDeploys/awaitStagingDeploys.ts index 26947193cd80..96bb17a14354 100644 --- a/.github/actions/javascript/awaitStagingDeploys/awaitStagingDeploys.ts +++ b/.github/actions/javascript/awaitStagingDeploys/awaitStagingDeploys.ts @@ -8,19 +8,10 @@ import {promiseDoWhile} from '@github/libs/promiseWhile'; type CurrentStagingDeploys = Awaited>['data']['workflow_runs']; function run() { - console.info('[awaitStagingDeploys] POLL RATE', CONST.POLL_RATE); - console.info('[awaitStagingDeploys] run()'); - console.info('[awaitStagingDeploys] getStringInput', getStringInput); - console.info('[awaitStagingDeploys] GitHubUtils', GitHubUtils); - console.info('[awaitStagingDeploys] promiseDoWhile', promiseDoWhile); - const tag = getStringInput('TAG', {required: false}); - console.info('[awaitStagingDeploys] run() tag', tag); let currentStagingDeploys: CurrentStagingDeploys = []; - console.info('[awaitStagingDeploys] run() _.throttle', lodashThrottle); - const throttleFunc = () => Promise.all([ // These are active deploys @@ -42,24 +33,20 @@ function run() { }), ]) .then((responses) => { - console.info('[awaitStagingDeploys] listWorkflowRuns responses', responses); const workflowRuns = responses[0].data.workflow_runs; if (!tag && typeof responses[1] === 'object') { workflowRuns.push(...responses[1].data.workflow_runs); } - console.info('[awaitStagingDeploys] workflowRuns', workflowRuns); return workflowRuns; }) .then((workflowRuns) => (currentStagingDeploys = workflowRuns.filter((workflowRun) => workflowRun.status !== 'completed'))) .then(() => { - console.info('[awaitStagingDeploys] currentStagingDeploys', currentStagingDeploys); console.log( !currentStagingDeploys.length ? 'No current staging deploys found' : `Found ${currentStagingDeploys.length} staging deploy${currentStagingDeploys.length > 1 ? 's' : ''} still running...`, ); }); - console.info('[awaitStagingDeploys] run() throttleFunc', throttleFunc); return promiseDoWhile( () => !!currentStagingDeploys.length, diff --git a/.github/actions/javascript/awaitStagingDeploys/index.js b/.github/actions/javascript/awaitStagingDeploys/index.js index c91313520673..0e0168fdb7ae 100644 --- a/.github/actions/javascript/awaitStagingDeploys/index.js +++ b/.github/actions/javascript/awaitStagingDeploys/index.js @@ -12131,15 +12131,8 @@ const CONST_1 = __importDefault(__nccwpck_require__(9873)); const GithubUtils_1 = __importDefault(__nccwpck_require__(9296)); const promiseWhile_1 = __nccwpck_require__(9438); function run() { - console.info('[awaitStagingDeploys] POLL RATE', CONST_1.default.POLL_RATE); - console.info('[awaitStagingDeploys] run()'); - console.info('[awaitStagingDeploys] getStringInput', ActionUtils_1.getStringInput); - console.info('[awaitStagingDeploys] GitHubUtils', GithubUtils_1.default); - console.info('[awaitStagingDeploys] promiseDoWhile', promiseWhile_1.promiseDoWhile); const tag = (0, ActionUtils_1.getStringInput)('TAG', { required: false }); - console.info('[awaitStagingDeploys] run() tag', tag); let currentStagingDeploys = []; - console.info('[awaitStagingDeploys] run() _.throttle', throttle_1.default); const throttleFunc = () => Promise.all([ // These are active deploys GithubUtils_1.default.octokit.actions.listWorkflowRuns({ @@ -12159,22 +12152,18 @@ function run() { }), ]) .then((responses) => { - console.info('[awaitStagingDeploys] listWorkflowRuns responses', responses); const workflowRuns = responses[0].data.workflow_runs; if (!tag && typeof responses[1] === 'object') { workflowRuns.push(...responses[1].data.workflow_runs); } - console.info('[awaitStagingDeploys] workflowRuns', workflowRuns); return workflowRuns; }) .then((workflowRuns) => (currentStagingDeploys = workflowRuns.filter((workflowRun) => workflowRun.status !== 'completed'))) .then(() => { - console.info('[awaitStagingDeploys] currentStagingDeploys', currentStagingDeploys); console.log(!currentStagingDeploys.length ? 'No current staging deploys found' : `Found ${currentStagingDeploys.length} staging deploy${currentStagingDeploys.length > 1 ? 's' : ''} still running...`); }); - console.info('[awaitStagingDeploys] run() throttleFunc', throttleFunc); return (0, promiseWhile_1.promiseDoWhile)(() => !!currentStagingDeploys.length, (0, throttle_1.default)(throttleFunc, // Poll every 60 seconds instead of every 10 seconds CONST_1.default.POLL_RATE * 6)); @@ -12730,7 +12719,6 @@ exports.promiseDoWhile = exports.promiseWhile = void 0; * Simulates a while loop where the condition is determined by the result of a Promise. */ function promiseWhile(condition, action) { - console.info('[promiseWhile] promiseWhile()'); return new Promise((resolve, reject) => { const loop = function () { if (!condition()) { @@ -12738,7 +12726,6 @@ function promiseWhile(condition, action) { } else { const actionResult = action?.(); - console.info('[promiseWhile] promiseWhile() actionResult', actionResult); if (!actionResult) { resolve(); return; @@ -12759,11 +12746,8 @@ exports.promiseWhile = promiseWhile; * Simulates a do-while loop where the condition is determined by the result of a Promise. */ function promiseDoWhile(condition, action) { - console.info('[promiseWhile] promiseDoWhile()'); return new Promise((resolve, reject) => { - console.info('[promiseWhile] promiseDoWhile() condition', condition); const actionResult = action?.(); - console.info('[promiseWhile] promiseDoWhile() actionResult', actionResult); if (!actionResult) { resolve(); return; diff --git a/.github/libs/promiseWhile.ts b/.github/libs/promiseWhile.ts index 401b6ee2e18a..8bedceb894fd 100644 --- a/.github/libs/promiseWhile.ts +++ b/.github/libs/promiseWhile.ts @@ -4,15 +4,12 @@ import type {DebouncedFunc} from 'lodash'; * Simulates a while loop where the condition is determined by the result of a Promise. */ function promiseWhile(condition: () => boolean, action: (() => Promise) | DebouncedFunc<() => Promise> | undefined): Promise { - console.info('[promiseWhile] promiseWhile()'); - return new Promise((resolve, reject) => { const loop = function () { if (!condition()) { resolve(); } else { const actionResult = action?.(); - console.info('[promiseWhile] promiseWhile() actionResult', actionResult); if (!actionResult) { resolve(); @@ -35,12 +32,9 @@ function promiseWhile(condition: () => boolean, action: (() => Promise) | * Simulates a do-while loop where the condition is determined by the result of a Promise. */ function promiseDoWhile(condition: () => boolean, action: (() => Promise) | DebouncedFunc<() => Promise> | undefined): Promise { - console.info('[promiseWhile] promiseDoWhile()'); - return new Promise((resolve, reject) => { - console.info('[promiseWhile] promiseDoWhile() condition', condition); const actionResult = action?.(); - console.info('[promiseWhile] promiseDoWhile() actionResult', actionResult); + if (!actionResult) { resolve(); return; diff --git a/.storybook/preview.tsx b/.storybook/preview.tsx index ec8e17dda4cf..7527857eeda7 100644 --- a/.storybook/preview.tsx +++ b/.storybook/preview.tsx @@ -16,7 +16,7 @@ import './fonts.css'; Onyx.init({ keys: ONYXKEYS, initialKeyStates: { - [ONYXKEYS.NETWORK]: {isOffline: false}, + [ONYXKEYS.NETWORK]: {isOffline: false, isBackendReachable: true}, }, }); diff --git a/android/app/build.gradle b/android/app/build.gradle index 9c06623cf751..abdc6765ec1a 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -98,8 +98,8 @@ android { minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion multiDexEnabled rootProject.ext.multiDexEnabled - versionCode 1001047301 - versionName "1.4.73-1" + versionCode 1001047400 + versionName "1.4.74-0" // Supported language variants must be declared here to avoid from being removed during the compilation. // This also helps us to not include unnecessary language variants in the APK. resConfigs "en", "es" diff --git a/ios/NewExpensify/Info.plist b/ios/NewExpensify/Info.plist index b6e91b0ce099..e318f45b4a6f 100644 --- a/ios/NewExpensify/Info.plist +++ b/ios/NewExpensify/Info.plist @@ -19,7 +19,7 @@ CFBundlePackageType APPL CFBundleShortVersionString - 1.4.73 + 1.4.74 CFBundleSignature ???? CFBundleURLTypes @@ -40,7 +40,7 @@ CFBundleVersion - 1.4.73.1 + 1.4.74.0 FullStory OrgId diff --git a/ios/NewExpensifyTests/Info.plist b/ios/NewExpensifyTests/Info.plist index 1752d3715bba..065ef2041e6e 100644 --- a/ios/NewExpensifyTests/Info.plist +++ b/ios/NewExpensifyTests/Info.plist @@ -15,10 +15,10 @@ CFBundlePackageType BNDL CFBundleShortVersionString - 1.4.73 + 1.4.74 CFBundleSignature ???? CFBundleVersion - 1.4.73.1 + 1.4.74.0 diff --git a/ios/NotificationServiceExtension/Info.plist b/ios/NotificationServiceExtension/Info.plist index 6199c193851f..afe45f0e2591 100644 --- a/ios/NotificationServiceExtension/Info.plist +++ b/ios/NotificationServiceExtension/Info.plist @@ -11,9 +11,9 @@ CFBundleName $(PRODUCT_NAME) CFBundleShortVersionString - 1.4.73 + 1.4.74 CFBundleVersion - 1.4.73.1 + 1.4.74.0 NSExtension NSExtensionPointIdentifier diff --git a/package-lock.json b/package-lock.json index d5a88239720c..d4169447b963 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "new.expensify", - "version": "1.4.73-1", + "version": "1.4.74-0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "new.expensify", - "version": "1.4.73-1", + "version": "1.4.74-0", "hasInstallScript": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index f1445aa2f8ff..6a61cffc24a5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "new.expensify", - "version": "1.4.73-1", + "version": "1.4.74-0", "author": "Expensify, Inc.", "homepage": "https://new.expensify.com", "description": "New Expensify is the next generation of Expensify: a reimagination of payments based atop a foundation of chat.", diff --git a/patches/react-native+0.73.4+015+copyStateOnClone.patch b/patches/react-native+0.73.4+015+copyStateOnClone.patch new file mode 100644 index 000000000000..9c6bac903efb --- /dev/null +++ b/patches/react-native+0.73.4+015+copyStateOnClone.patch @@ -0,0 +1,12 @@ +diff --git a/node_modules/react-native/ReactCommon/react/renderer/core/ShadowNode.cpp b/node_modules/react-native/ReactCommon/react/renderer/core/ShadowNode.cpp +index 641b6d2..6adeb1b 100644 +--- a/node_modules/react-native/ReactCommon/react/renderer/core/ShadowNode.cpp ++++ b/node_modules/react-native/ReactCommon/react/renderer/core/ShadowNode.cpp +@@ -331,6 +331,7 @@ ShadowNode::Unshared ShadowNode::cloneTree( + childNode = parentNode.clone({ + ShadowNodeFragment::propsPlaceholder(), + std::make_shared(children), ++ parentNode.getState(), + }); + } + diff --git a/src/CONST.ts b/src/CONST.ts index 6adcf5dfb785..c7bdbb2b35e7 100755 --- a/src/CONST.ts +++ b/src/CONST.ts @@ -555,8 +555,10 @@ const CONST = { EMPTY_ARRAY, EMPTY_OBJECT, USE_EXPENSIFY_URL, + STATUS_EXPENSIFY_URL: 'https://status.expensify.com', GOOGLE_MEET_URL_ANDROID: 'https://meet.google.com', GOOGLE_DOC_IMAGE_LINK_MATCH: 'googleusercontent.com', + GOOGLE_CLOUD_URL: 'https://clients3.google.com/generate_204', IMAGE_BASE64_MATCH: 'base64', DEEPLINK_BASE_URL: 'new-expensify://', PDF_VIEWER_URL: '/pdf/web/viewer.html', @@ -936,9 +938,12 @@ const CONST = { MERCHANT: 'merchant', FROM: 'from', TO: 'to', + CATEGORY: 'category', + TAG: 'tag', TOTAL: 'total', TYPE: 'type', ACTION: 'action', + TAX_AMOUNT: 'taxAmount', }, PRIORITY_MODE: { GSD: 'gsd', @@ -1044,6 +1049,7 @@ const CONST = { MAX_RETRY_WAIT_TIME_MS: 10 * 1000, PROCESS_REQUEST_DELAY_MS: 1000, MAX_PENDING_TIME_MS: 10 * 1000, + BACKEND_CHECK_INTERVAL_MS: 60 * 1000, MAX_REQUEST_RETRIES: 10, NETWORK_STATUS: { ONLINE: 'online', @@ -1055,7 +1061,7 @@ const CONST = { DEFAULT_TIME_ZONE: {automatic: true, selected: 'America/Los_Angeles'}, DEFAULT_ACCOUNT_DATA: {errors: null, success: '', isLoading: false}, DEFAULT_CLOSE_ACCOUNT_DATA: {errors: null, success: '', isLoading: false}, - DEFAULT_NETWORK_DATA: {isOffline: false}, + DEFAULT_NETWORK_DATA: {isOffline: false, isBackendReachable: true}, FORMS: { LOGIN_FORM: 'LoginForm', VALIDATE_CODE_FORM: 'ValidateCodeForm', @@ -1298,8 +1304,15 @@ const CONST = { XERO_CONFIG: { AUTO_SYNC: 'autoSync', SYNC: 'sync', + ENABLE_NEW_CATEGORIES: 'enableNewCategories', + EXPORT: 'export', IMPORT_CUSTOMERS: 'importCustomers', IMPORT_TAX_RATES: 'importTaxRates', + INVOICE_STATUS: { + AWAITING_PAYMENT: 'AWT_PAYMENT', + DRAFT: 'DRAFT', + AWAITING_APPROVAL: 'AWT_APPROVAL', + }, IMPORT_TRACKING_CATEGORIES: 'importTrackingCategories', MAPPINGS: 'mappings', TRACKING_CATEGORY_PREFIX: 'trackingCategory_', @@ -1319,6 +1332,12 @@ const CONST = { JOURNAL_ENTRY: 'journal_entry', }, + XERO_EXPORT_DATE: { + LAST_EXPENSE: 'LAST_EXPENSE', + REPORT_EXPORTED: 'REPORT_EXPORTED', + REPORT_SUBMITTED: 'REPORT_SUBMITTED', + }, + QUICKBOOKS_EXPORT_DATE: { LAST_EXPENSE: 'LAST_EXPENSE', REPORT_EXPORTED: 'REPORT_EXPORTED', @@ -1784,6 +1803,8 @@ const CONST = { XERO_SYNC_IMPORT_CUSTOMERS: 'xeroSyncImportCustomers', XERO_SYNC_IMPORT_BANK_ACCOUNTS: 'xeroSyncImportBankAccounts', XERO_SYNC_IMPORT_TAX_RATES: 'xeroSyncImportTaxRates', + XERO_CHECK_CONNECTION: 'xeroCheckConnection', + XERO_SYNC_TITLE: 'xeroSyncTitle', }, }, ACCESS_VARIANTS: { @@ -3563,10 +3584,10 @@ const CONST = { BACK_BUTTON_NATIVE_ID: 'backButton', /** - * The maximum count of items per page for OptionsSelector. + * The maximum count of items per page for SelectionList. * When paginate, it multiplies by page number. */ - MAX_OPTIONS_SELECTOR_PAGE_LENGTH: 500, + MAX_SELECTION_LIST_PAGE_LENGTH: 500, /** * Bank account names @@ -4746,6 +4767,8 @@ const CONST = { DISTANCE: 'distance', }, + SEARCH_BOTTOM_TAB_URL: '/Search_Bottom_Tab', + SEARCH_DATA_TYPES: { TRANSACTION: 'transaction', }, diff --git a/src/Expensify.tsx b/src/Expensify.tsx index c67f1400fc4b..e91a5223d7b6 100644 --- a/src/Expensify.tsx +++ b/src/Expensify.tsx @@ -140,8 +140,10 @@ function Expensify({ // Initialize this client as being an active client ActiveClientManager.init(); - // Used for the offline indicator appearing when someone is offline - NetworkConnection.subscribeToNetInfo(); + // Used for the offline indicator appearing when someone is offline or backend is unreachable + const unsubscribeNetworkStatus = NetworkConnection.subscribeToNetworkStatus(); + + return () => unsubscribeNetworkStatus(); }, []); useEffect(() => { diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts index 804c8dadd553..ddf37fba2354 100755 --- a/src/ONYXKEYS.ts +++ b/src/ONYXKEYS.ts @@ -282,6 +282,9 @@ const ONYXKEYS = { /** Onboarding Purpose selected by the user during Onboarding flow */ ONBOARDING_PURPOSE_SELECTED: 'onboardingPurposeSelected', + /** Onboarding policyID selected by the user during Onboarding flow */ + ONBOARDING_POLICY_ID: 'onboardingPolicyID', + /** Onboarding Purpose selected by the user during Onboarding flow */ ONBOARDING_ADMINS_CHAT_REPORT_ID: 'onboardingAdminsChatReportID', @@ -336,6 +339,7 @@ const ONYXKEYS = { WORKSPACE_INVITE_MEMBERS_DRAFT: 'workspaceInviteMembersDraft_', WORKSPACE_INVITE_MESSAGE_DRAFT: 'workspaceInviteMessageDraft_', REPORT: 'report_', + REPORT_NAME_VALUE_PAIRS: 'reportNameValuePairs_', REPORT_DRAFT: 'reportDraft_', // REPORT_METADATA is a perf optimization used to hold loading states (isLoadingInitialReportActions, isLoadingOlderReportActions, isLoadingNewerReportActions). // A lot of components are connected to the Report entity and do not care about the actions. Setting the loading state @@ -548,6 +552,7 @@ type OnyxCollectionValuesMapping = { [ONYXKEYS.COLLECTION.WORKSPACE_INVITE_MEMBERS_DRAFT]: OnyxTypes.InvitedEmailsToAccountIDs; [ONYXKEYS.COLLECTION.WORKSPACE_INVITE_MESSAGE_DRAFT]: string; [ONYXKEYS.COLLECTION.REPORT]: OnyxTypes.Report; + [ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS]: OnyxTypes.ReportNameValuePairs; [ONYXKEYS.COLLECTION.REPORT_DRAFT]: OnyxTypes.Report; [ONYXKEYS.COLLECTION.REPORT_METADATA]: OnyxTypes.ReportMetadata; [ONYXKEYS.COLLECTION.REPORT_ACTIONS]: OnyxTypes.ReportActions; @@ -662,6 +667,7 @@ type OnyxValuesMapping = { [ONYXKEYS.MAX_CANVAS_HEIGHT]: number; [ONYXKEYS.MAX_CANVAS_WIDTH]: number; [ONYXKEYS.ONBOARDING_PURPOSE_SELECTED]: string; + [ONYXKEYS.ONBOARDING_POLICY_ID]: string; [ONYXKEYS.ONBOARDING_ADMINS_CHAT_REPORT_ID]: string; [ONYXKEYS.IS_SEARCHING_FOR_REPORTS]: boolean; [ONYXKEYS.LAST_VISITED_PATH]: string | undefined; diff --git a/src/ROUTES.ts b/src/ROUTES.ts index 900d12977a35..0dff2992fe91 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -787,6 +787,10 @@ const ROUTES = { route: 'settings/workspaces/:policyID/accounting/xero/import', getRoute: (policyID: string) => `settings/workspaces/${policyID}/accounting/xero/import` as const, }, + POLICY_ACCOUNTING_XERO_CHART_OF_ACCOUNTS: { + route: 'settings/workspaces/:policyID/accounting/xero/import/accounts', + getRoute: (policyID: string) => `settings/workspaces/${policyID}/accounting/xero/import/accounts` as const, + }, POLICY_ACCOUNTING_XERO_ORGANIZATION: { route: 'settings/workspaces/:policyID/accounting/xero/organization/:currentOrganizationID', getRoute: (policyID: string, currentOrganizationID: string) => `settings/workspaces/${policyID}/accounting/xero/organization/${currentOrganizationID}` as const, @@ -815,6 +819,18 @@ const ROUTES = { route: 'settings/workspaces/:policyID/accounting/xero/export', getRoute: (policyID: string) => `settings/workspaces/${policyID}/accounting/xero/export` as const, }, + POLICY_ACCOUNTING_XERO_PREFERRED_EXPORTER_SELECT: { + route: '/settings/workspaces/:policyID/connections/xero/export/preferred-exporter/select', + getRoute: (policyID: string) => `/settings/workspaces/${policyID}/connections/xero/export/preferred-exporter/select` as const, + }, + POLICY_ACCOUNTING_XERO_EXPORT_PURCHASE_BILL_DATE_SELECT: { + route: 'settings/workspaces/:policyID/accounting/xero/export/purchase-bill-date-select', + getRoute: (policyID: string) => `settings/workspaces/${policyID}/accounting/xero/export/purchase-bill-date-select` as const, + }, + POLICY_ACCOUNTING_XERO_EXPORT_BANK_ACCOUNT_SELECT: { + route: 'settings/workspaces/:policyID/accounting/xero/export/bank-account-select', + getRoute: (policyID: string) => `settings/workspaces/${policyID}/accounting/xero/export/bank-account-select` as const, + }, POLICY_ACCOUNTING_XERO_ADVANCED: { route: 'settings/workspaces/:policyID/accounting/xero/advanced', getRoute: (policyID: string) => `settings/workspaces/${policyID}/accounting/xero/advanced` as const, @@ -823,6 +839,10 @@ const ROUTES = { route: 'settings/workspaces/:policyID/accounting/xero/advanced/invoice-account-selector', getRoute: (policyID: string) => `settings/workspaces/${policyID}/accounting/xero/advanced/invoice-account-selector` as const, }, + POLICY_ACCOUNTING_XERO_BILL_PAYMENT_ACCOUNT_SELECTOR: { + route: 'settings/workspaces/:policyID/accounting/xero/advanced/bill-payment-account-selector', + getRoute: (policyID: string) => `settings/workspaces/${policyID}/accounting/xero/advanced/bill-payment-account-selector` as const, + }, POLICY_ACCOUNTING_QUICKBOOKS_ONLINE_IMPORT: { route: 'settings/workspaces/:policyID/accounting/quickbooks-online/import', getRoute: (policyID: string) => `settings/workspaces/${policyID}/accounting/quickbooks-online/import` as const, diff --git a/src/SCREENS.ts b/src/SCREENS.ts index c640ff1ddb7c..e25b9776919a 100644 --- a/src/SCREENS.ts +++ b/src/SCREENS.ts @@ -27,7 +27,6 @@ const SCREENS = { MY_TRIPS: 'Travel_MyTrips', TCS: 'Travel_TCS', }, - WORKSPACES_CENTRAL_PANE: 'WorkspacesCentralPane', SEARCH: { CENTRAL_PANE: 'Search_Central_Pane', REPORT_RHP: 'Search_Report_RHP', @@ -112,9 +111,6 @@ const SCREENS = { CHAT_FINDER: 'ChatFinder', WORKSPACE_SWITCHER: 'WorkspaceSwitcher', }, - WORKSPACE_SWITCHER: { - ROOT: 'WorkspaceSwitcher_Root', - }, RIGHT_MODAL: { SETTINGS: 'Settings', NEW_CHAT: 'NewChat', @@ -245,14 +241,19 @@ const SCREENS = { QUICKBOOKS_ONLINE_INVOICE_ACCOUNT_SELECTOR: 'Policy_Accounting_Quickbooks_Online_Invoice_Account_Selector', XERO_IMPORT: 'Policy_Accounting_Xero_Import', XERO_ORGANIZATION: 'Policy_Accounting_Xero_Customers', + XERO_CHART_OF_ACCOUNTS: 'Policy_Accounting_Xero_Import_Chart_Of_Accounts', XERO_CUSTOMER: 'Policy_Acounting_Xero_Import_Customer', XERO_TAXES: 'Policy_Accounting_Xero_Taxes', XERO_TRACKING_CATEGORIES: 'Policy_Accounting_Xero_Tracking_Categories', XERO_MAP_COST_CENTERS: 'Policy_Accounting_Xero_Map_Cost_Centers', XERO_MAP_REGION: 'Policy_Accounting_Xero_Map_Region', XERO_EXPORT: 'Policy_Accounting_Xero_Export', + XERO_EXPORT_PURCHASE_BILL_DATE_SELECT: 'Policy_Accounting_Xero_Export_Purchase_Bill_Date_Select', XERO_ADVANCED: 'Policy_Accounting_Xero_Advanced', XERO_INVOICE_ACCOUNT_SELECTOR: 'Policy_Accounting_Xero_Invoice_Account_Selector', + XERO_EXPORT_PREFERRED_EXPORTER_SELECT: 'Workspace_Accounting_Xero_Export_Preferred_Exporter_Select', + XERO_BILL_PAYMENT_ACCOUNT_SELECTOR: 'Policy_Accounting_Xero_Bill_Payment_Account_Selector', + XERO_EXPORT_BANK_ACCOUNT_SELECT: 'Policy_Accounting_Xero_Export_Bank_Account_Select', }, INITIAL: 'Workspace_Initial', PROFILE: 'Workspace_Profile', @@ -359,7 +360,6 @@ const SCREENS = { }, ROOM_MEMBERS_ROOT: 'RoomMembers_Root', ROOM_INVITE_ROOT: 'RoomInvite_Root', - CHAT_FINDER_ROOT: 'ChatFinder_Root', FLAG_COMMENT_ROOT: 'FlagComment_Root', REIMBURSEMENT_ACCOUNT: 'ReimbursementAccount', GET_ASSISTANCE: 'GetAssistance', diff --git a/src/components/AccountingListSkeletonView.tsx b/src/components/AccountingListSkeletonView.tsx new file mode 100644 index 000000000000..b977903d3adc --- /dev/null +++ b/src/components/AccountingListSkeletonView.tsx @@ -0,0 +1,34 @@ +import React from 'react'; +import {Circle, Rect} from 'react-native-svg'; +import ItemListSkeletonView from './Skeletons/ItemListSkeletonView'; + +type AccountingListSkeletonViewProps = { + shouldAnimate?: boolean; +}; + +function AccountingListSkeletonView({shouldAnimate = true}: AccountingListSkeletonViewProps) { + return ( + ( + <> + + + + )} + /> + ); +} + +AccountingListSkeletonView.displayName = 'AccountingListSkeletonView'; + +export default AccountingListSkeletonView; diff --git a/src/components/AmountPicker/index.tsx b/src/components/AmountPicker/index.tsx index 45e511f24748..014932f7736b 100644 --- a/src/components/AmountPicker/index.tsx +++ b/src/components/AmountPicker/index.tsx @@ -1,17 +1,15 @@ import React, {forwardRef, useState} from 'react'; import type {ForwardedRef} from 'react'; import {View} from 'react-native'; -import FormHelpMessage from '@components/FormHelpMessage'; import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; import useStyleUtils from '@hooks/useStyleUtils'; -import useThemeStyles from '@hooks/useThemeStyles'; import variables from '@styles/variables'; +import CONST from '@src/CONST'; import callOrReturn from '@src/types/utils/callOrReturn'; import AmountSelectorModal from './AmountSelectorModal'; import type {AmountPickerProps} from './types'; function AmountPicker({value, description, title, errorText = '', onInputChange, furtherDetails, rightLabel, ...rest}: AmountPickerProps, forwardedRef: ForwardedRef) { - const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); const [isPickerVisible, setIsPickerVisible] = useState(false); @@ -43,11 +41,10 @@ function AmountPicker({value, description, title, errorText = '', onInputChange, description={description} onPress={showPickerModal} furtherDetails={furtherDetails} + brickRoadIndicator={errorText ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined} rightLabel={rightLabel} + errorText={errorText} /> - - - this.props.maxIndex && this.props.shouldResetIndexOnEndReached) { - this.onArrowDownKey(); - } - } - - componentWillUnmount() { - if (this.unsubscribeArrowUpKey) { - this.unsubscribeArrowUpKey(); - } - - if (this.unsubscribeArrowDownKey) { - this.unsubscribeArrowDownKey(); - } - } - - onArrowUpKey() { - if (this.props.maxIndex < 0 || !this.props.isFocused) { - return; - } - - const currentFocusedIndex = this.props.focusedIndex > 0 ? this.props.focusedIndex - 1 : this.props.maxIndex; - let newFocusedIndex = currentFocusedIndex; - - while (this.props.disabledIndexes.includes(newFocusedIndex)) { - newFocusedIndex = newFocusedIndex > 0 ? newFocusedIndex - 1 : this.props.maxIndex; - if (newFocusedIndex === currentFocusedIndex) { - // all indexes are disabled - return; // no-op - } - } - - this.props.onFocusedIndexChanged(newFocusedIndex); - } - - onArrowDownKey() { - if (this.props.maxIndex < 0 || !this.props.isFocused) { - return; - } - - const currentFocusedIndex = this.props.focusedIndex < this.props.maxIndex ? this.props.focusedIndex + 1 : 0; - let newFocusedIndex = currentFocusedIndex; - - while (this.props.disabledIndexes.includes(newFocusedIndex)) { - newFocusedIndex = newFocusedIndex < this.props.maxIndex ? newFocusedIndex + 1 : 0; - if (newFocusedIndex === currentFocusedIndex) { - // all indexes are disabled - return; // no-op - } - } - - this.props.onFocusedIndexChanged(newFocusedIndex); - } - - render() { - return this.props.children; - } -} - -function ArrowKeyFocusManager(props) { - const isFocused = useIsFocused(); - - return ( - - ); -} - -BaseArrowKeyFocusManager.propTypes = propTypes; -BaseArrowKeyFocusManager.defaultProps = defaultProps; -ArrowKeyFocusManager.displayName = 'ArrowKeyFocusManager'; - -export default ArrowKeyFocusManager; diff --git a/src/components/AttachmentModal.tsx b/src/components/AttachmentModal.tsx index f697142c556e..fb6a8e911e87 100644 --- a/src/components/AttachmentModal.tsx +++ b/src/components/AttachmentModal.tsx @@ -456,7 +456,7 @@ function AttachmentModal({ } const context = useMemo( () => ({ - pagerItems: [], + pagerItems: [{source: sourceForAttachmentView, index: 0, isActive: true}], activePage: 0, pagerRef: undefined, isPagerScrolling: nope, @@ -465,7 +465,7 @@ function AttachmentModal({ onScaleChanged: () => {}, onSwipeDown: closeModal, }), - [closeModal, nope], + [closeModal, nope, sourceForAttachmentView], ); return ( diff --git a/src/components/AttachmentOfflineIndicator.tsx b/src/components/AttachmentOfflineIndicator.tsx new file mode 100644 index 000000000000..d425e6f18e0e --- /dev/null +++ b/src/components/AttachmentOfflineIndicator.tsx @@ -0,0 +1,57 @@ +import React, {useEffect, useState} from 'react'; +import {View} from 'react-native'; +import useLocalize from '@hooks/useLocalize'; +import useNetwork from '@hooks/useNetwork'; +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 Text from './Text'; + +type AttachmentOfflineIndicatorProps = { + /** Whether the offline indicator is displayed for the attachment preview. */ + isPreview?: boolean; +}; + +function AttachmentOfflineIndicator({isPreview = false}: AttachmentOfflineIndicatorProps) { + const theme = useTheme(); + const styles = useThemeStyles(); + const {isOffline} = useNetwork(); + const {translate} = useLocalize(); + + // We don't want to show the offline indicator when the attachment is a cached one, so + // we delay the display by 200 ms to ensure it is not a cached one. + const [onCacheDelay, setOnCacheDelay] = useState(true); + + useEffect(() => { + const timeout = setTimeout(() => setOnCacheDelay(false), 200); + + return () => clearTimeout(timeout); + }, []); + + if (!isOffline || onCacheDelay) { + return null; + } + + return ( + + + {!isPreview && ( + + {translate('common.youAppearToBeOffline')} + {translate('common.attachementWillBeAvailableOnceBackOnline')} + + )} + + ); +} + +AttachmentOfflineIndicator.displayName = 'AttachmentOfflineIndicator'; + +export default AttachmentOfflineIndicator; diff --git a/src/components/Avatar.tsx b/src/components/Avatar.tsx index 4acf197ba178..f633869ccd30 100644 --- a/src/components/Avatar.tsx +++ b/src/components/Avatar.tsx @@ -52,7 +52,7 @@ type AvatarProps = { name?: string; /** Optional account id if it's user avatar or policy id if it's workspace avatar */ - accountID?: number | string; + avatarID?: number | string; }; function Avatar({ @@ -66,7 +66,7 @@ function Avatar({ fallbackIconTestID = '', type = CONST.ICON_TYPE_AVATAR, name = '', - accountID, + avatarID, }: AvatarProps) { const theme = useTheme(); const styles = useThemeStyles(); @@ -86,7 +86,7 @@ function Avatar({ const iconStyle = imageStyles ? [StyleUtils.getAvatarStyle(size), styles.bgTransparent, imageStyles] : undefined; // We pass the color styles down to the SVG for the workspace and fallback avatar. - const source = isWorkspace ? originalSource : UserUtils.getAvatar(originalSource, Number(accountID)); + const source = isWorkspace ? originalSource : UserUtils.getAvatar(originalSource, Number(avatarID)); const useFallBackAvatar = imageError || !source || source === Expensicons.FallbackAvatar; const fallbackAvatar = isWorkspace ? ReportUtils.getDefaultWorkspaceAvatar(name) : fallbackIcon || Expensicons.FallbackAvatar; const fallbackAvatarTestID = isWorkspace ? ReportUtils.getDefaultWorkspaceAvatarTestID(name) : fallbackIconTestID || 'SvgFallbackAvatar Icon'; @@ -94,7 +94,7 @@ function Avatar({ let iconColors; if (isWorkspace) { - iconColors = StyleUtils.getDefaultWorkspaceAvatarColor(accountID?.toString() ?? ''); + iconColors = StyleUtils.getDefaultWorkspaceAvatarColor(avatarID?.toString() ?? ''); } else if (useFallBackAvatar) { iconColors = StyleUtils.getBackgroundColorAndFill(theme.buttonHoveredBG, theme.icon); } else { diff --git a/src/components/CategoryPicker.tsx b/src/components/CategoryPicker.tsx index 40ebebdd0488..e5c85a8f5f6d 100644 --- a/src/components/CategoryPicker.tsx +++ b/src/components/CategoryPicker.tsx @@ -45,6 +45,7 @@ function CategoryPicker({selectedCategory, policyCategories, policyRecentlyUsedC }, [selectedCategory]); const [sections, headerMessage, shouldShowTextInput] = useMemo(() => { + const categories = policyCategories ?? policyCategoriesDraft ?? {}; const validPolicyRecentlyUsedCategories = policyRecentlyUsedCategories?.filter?.((p) => !isEmptyObject(p)); const {categoryOptions} = OptionsListUtils.getFilteredOptions( [], @@ -56,15 +57,15 @@ function CategoryPicker({selectedCategory, policyCategories, policyRecentlyUsedC false, false, true, - policyCategories ?? policyCategoriesDraft ?? {}, + categories, validPolicyRecentlyUsedCategories, false, ); const categoryData = categoryOptions?.[0]?.data ?? []; const header = OptionsListUtils.getHeaderMessageForNonUserList(categoryData.length > 0, debouncedSearchValue); - const policiesCount = OptionsListUtils.getEnabledCategoriesCount(policyCategories ?? {}); - const isCategoriesCountBelowThreshold = policiesCount < CONST.CATEGORY_LIST_THRESHOLD; + const categoriesCount = OptionsListUtils.getEnabledCategoriesCount(categories); + const isCategoriesCountBelowThreshold = categoriesCount < CONST.CATEGORY_LIST_THRESHOLD; const showInput = !isCategoriesCountBelowThreshold; return [categoryOptions, header, showInput]; diff --git a/src/components/CollapsibleSection/index.tsx b/src/components/CollapsibleSection/index.tsx index f984906595c2..d339f005e3d3 100644 --- a/src/components/CollapsibleSection/index.tsx +++ b/src/components/CollapsibleSection/index.tsx @@ -18,6 +18,9 @@ type CollapsibleSectionProps = ChildrenProps & { /** Style of title of the collapsible section */ titleStyle?: StyleProp; + /** Style for the text */ + textStyle?: StyleProp; + /** Style for the wrapper view */ wrapperStyle?: StyleProp; @@ -25,7 +28,7 @@ type CollapsibleSectionProps = ChildrenProps & { shouldShowSectionBorder?: boolean; }; -function CollapsibleSection({title, children, titleStyle, wrapperStyle, shouldShowSectionBorder}: CollapsibleSectionProps) { +function CollapsibleSection({title, children, titleStyle, textStyle, wrapperStyle, shouldShowSectionBorder}: CollapsibleSectionProps) { const theme = useTheme(); const styles = useThemeStyles(); const [isExpanded, setIsExpanded] = useState(false); @@ -50,7 +53,7 @@ function CollapsibleSection({title, children, titleStyle, wrapperStyle, shouldSh pressDimmingValue={0.2} > {title} diff --git a/src/components/Composer/index.native.tsx b/src/components/Composer/index.native.tsx index 4d135cdd88e2..ac7eb95191ee 100644 --- a/src/components/Composer/index.native.tsx +++ b/src/components/Composer/index.native.tsx @@ -27,6 +27,7 @@ function Composer( // On Android the selection prop is required on the TextInput but this prop has issues on IOS selection, value, + isGroupPolicyReport = false, ...props }: ComposerProps, ref: ForwardedRef, @@ -34,7 +35,7 @@ function Composer( const textInput = useRef(null); const {isFocused, shouldResetFocus} = useResetComposerFocus(textInput); const theme = useTheme(); - const markdownStyle = useMarkdownStyle(value); + const markdownStyle = useMarkdownStyle(value, !isGroupPolicyReport ? ['mentionReport'] : []); const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); diff --git a/src/components/Composer/index.tsx b/src/components/Composer/index.tsx index 4bc54d13b056..f7bf277050a2 100755 --- a/src/components/Composer/index.tsx +++ b/src/components/Composer/index.tsx @@ -70,13 +70,14 @@ function Composer( isReportActionCompose = false, isComposerFullSize = false, shouldContainScroll = false, + isGroupPolicyReport = false, ...props }: ComposerProps, ref: ForwardedRef, ) { const theme = useTheme(); const styles = useThemeStyles(); - const markdownStyle = useMarkdownStyle(value); + const markdownStyle = useMarkdownStyle(value, !isGroupPolicyReport ? ['mentionReport'] : []); const StyleUtils = useStyleUtils(); const textRef = useRef(null); const textInput = useRef(null); diff --git a/src/components/Composer/types.ts b/src/components/Composer/types.ts index 531bcd03f8bf..0ff91111bd07 100644 --- a/src/components/Composer/types.ts +++ b/src/components/Composer/types.ts @@ -70,6 +70,9 @@ type ComposerProps = TextInputProps & { /** Should make the input only scroll inside the element avoid scroll out to parent */ shouldContainScroll?: boolean; + + /** Indicates whether the composer is in a group policy report. Used for disabling report mentioning style in markdown input */ + isGroupPolicyReport?: boolean; }; export type {TextSelection, ComposerProps}; diff --git a/src/components/ConnectToQuickbooksOnlineButton/index.native.tsx b/src/components/ConnectToQuickbooksOnlineButton/index.native.tsx index 212674e2a125..7e1d81cc4071 100644 --- a/src/components/ConnectToQuickbooksOnlineButton/index.native.tsx +++ b/src/components/ConnectToQuickbooksOnlineButton/index.native.tsx @@ -9,6 +9,7 @@ import FullScreenLoadingIndicator from '@components/FullscreenLoadingIndicator'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import Modal from '@components/Modal'; import useLocalize from '@hooks/useLocalize'; +import useNetwork from '@hooks/useNetwork'; import useThemeStyles from '@hooks/useThemeStyles'; import {removePolicyConnection} from '@libs/actions/connections'; import {getQuickBooksOnlineSetupLink} from '@libs/actions/connections/QuickBooksOnline'; @@ -34,6 +35,7 @@ function ConnectToQuickbooksOnlineButton({ const {translate} = useLocalize(); const webViewRef = useRef(null); const [isWebViewOpen, setWebViewOpen] = useState(false); + const {isOffline} = useNetwork(); const authToken = session?.authToken ?? null; @@ -52,6 +54,7 @@ function ConnectToQuickbooksOnlineButton({ text={translate('workspace.accounting.setup')} style={styles.justifyContentCenter} small + isDisabled={isOffline} /> {shouldDisconnectIntegrationBeforeConnecting && integrationToDisconnect && isDisconnectModalOpen && ( ; const [isDisconnectModalOpen, setIsDisconnectModalOpen] = useState(false); @@ -47,6 +49,7 @@ function ConnectToXeroButton({policyID, session, shouldDisconnectIntegrationBefo text={translate('workspace.accounting.setup')} style={styles.justifyContentCenter} small + isDisabled={isOffline} /> {shouldDisconnectIntegrationBeforeConnecting && isDisconnectModalOpen && integrationToDisconnect && ( {shouldDisconnectIntegrationBeforeConnecting && isDisconnectModalOpen && integrationToDisconnect && ( | undefined; - /** Style of the subtitle text */ - subTitleStyle?: StyleProp | undefined; - /** Whether to use ScrollView or not */ shouldUseScrollView?: boolean; }; -type ConnectionLayoutContentProps = Pick; +type ConnectionLayoutContentProps = Pick; -function ConnectionLayoutContent({title, titleStyle, subtitle, subTitleStyle, children}: ConnectionLayoutContentProps) { +function ConnectionLayoutContent({title, titleStyle, children}: ConnectionLayoutContentProps) { const {translate} = useLocalize(); const styles = useThemeStyles(); return ( <> {title && {translate(title)}} - {subtitle && {translate(subtitle)}} {children} ); @@ -70,13 +66,12 @@ function ConnectionLayout({ headerTitle, children, title, - subtitle, + headerSubtitle, policyID, accessVariants, featureName, contentContainerStyle, titleStyle, - subTitleStyle, shouldUseScrollView = true, }: ConnectionLayoutProps) { const {translate} = useLocalize(); @@ -85,14 +80,12 @@ function ConnectionLayout({ () => ( {children} ), - [title, subtitle, titleStyle, subTitleStyle, children], + [title, titleStyle, children], ); return ( @@ -108,6 +101,7 @@ function ConnectionLayout({ > Navigation.goBack()} /> {shouldUseScrollView ? ( diff --git a/src/components/CountrySelector.tsx b/src/components/CountrySelector.tsx index dc0201747da2..002c0c6d4b0a 100644 --- a/src/components/CountrySelector.tsx +++ b/src/components/CountrySelector.tsx @@ -1,14 +1,14 @@ import {useIsFocused} from '@react-navigation/native'; import React, {forwardRef, useEffect, useRef} from 'react'; import type {ForwardedRef} from 'react'; -import {View} from 'react-native'; +import type {View} from 'react-native'; import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; import type {MaybePhraseKey} from '@libs/Localize'; import Navigation from '@libs/Navigation/Navigation'; +import CONST from '@src/CONST'; import type {Country} from '@src/CONST'; import ROUTES from '@src/ROUTES'; -import FormHelpMessage from './FormHelpMessage'; import MenuItemWithTopDescription from './MenuItemWithTopDescription'; type CountrySelectorProps = { @@ -53,23 +53,20 @@ function CountrySelector({errorText = '', value: countryCode, onInputChange = () }, [countryCode]); return ( - - { - const activeRoute = Navigation.getActiveRouteWithoutParams(); - didOpenContrySelector.current = true; - Navigation.navigate(ROUTES.SETTINGS_ADDRESS_COUNTRY.getRoute(countryCode ?? '', activeRoute)); - }} - /> - - - - + { + const activeRoute = Navigation.getActiveRouteWithoutParams(); + didOpenContrySelector.current = true; + Navigation.navigate(ROUTES.SETTINGS_ADDRESS_COUNTRY.getRoute(countryCode ?? '', activeRoute)); + }} + /> ); } diff --git a/src/components/FixedFooter.tsx b/src/components/FixedFooter.tsx index 2f09b27f3067..c9d60f3ced46 100644 --- a/src/components/FixedFooter.tsx +++ b/src/components/FixedFooter.tsx @@ -2,7 +2,6 @@ import type {ReactNode} from 'react'; import React from 'react'; import type {StyleProp, ViewStyle} from 'react-native'; import {View} from 'react-native'; -import useKeyboardState from '@hooks/useKeyboardState'; import useSafeAreaInsets from '@hooks/useSafeAreaInsets'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -15,7 +14,6 @@ type FixedFooterProps = { }; function FixedFooter({style, children}: FixedFooterProps) { - const {isKeyboardShown} = useKeyboardState(); const insets = useSafeAreaInsets(); const styles = useThemeStyles(); @@ -23,7 +21,7 @@ function FixedFooter({style, children}: FixedFooterProps) { return null; } - const shouldAddBottomPadding = isKeyboardShown || !insets.bottom; + const shouldAddBottomPadding = !insets.bottom; return {children}; } diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/MentionUserRenderer.tsx b/src/components/HTMLEngineProvider/HTMLRenderers/MentionUserRenderer.tsx index 0c7c82534bbb..504ddecb492b 100644 --- a/src/components/HTMLEngineProvider/HTMLRenderers/MentionUserRenderer.tsx +++ b/src/components/HTMLEngineProvider/HTMLRenderers/MentionUserRenderer.tsx @@ -105,7 +105,7 @@ function MentionUserRenderer({style, tnode, TDefaultRenderer, currentUserPersona {...defaultRendererProps} style={[styles.link, styleWithoutColor, StyleUtils.getMentionStyle(isOurMention), {color: StyleUtils.getMentionTextColor(isOurMention)}]} role={CONST.ROLE.LINK} - testID="span" + testID="mention-user" href={`/${navigationRoute}`} > {htmlAttribAccountID ? `@${mentionDisplayText}` : } diff --git a/src/components/HeaderWithBackButton/index.tsx b/src/components/HeaderWithBackButton/index.tsx index 70f1b97c04b1..78cd92dbc223 100755 --- a/src/components/HeaderWithBackButton/index.tsx +++ b/src/components/HeaderWithBackButton/index.tsx @@ -182,7 +182,7 @@ function HeaderWithBackButton({ containerStyles={[StyleUtils.getWidthAndHeightStyle(StyleUtils.getAvatarSize(CONST.AVATAR_SIZE.DEFAULT)), styles.mr3]} source={policyAvatar?.source} name={policyAvatar?.name} - accountID={policyAvatar?.id} + avatarID={policyAvatar?.id} type={policyAvatar?.type} /> )} diff --git a/src/components/ImageView/index.tsx b/src/components/ImageView/index.tsx index 5d09e7abf41d..f08941ef7d77 100644 --- a/src/components/ImageView/index.tsx +++ b/src/components/ImageView/index.tsx @@ -2,11 +2,13 @@ import type {SyntheticEvent} from 'react'; import React, {useCallback, useEffect, useRef, useState} from 'react'; import type {GestureResponderEvent, LayoutChangeEvent} from 'react-native'; import {View} from 'react-native'; +import AttachmentOfflineIndicator from '@components/AttachmentOfflineIndicator'; import FullscreenLoadingIndicator from '@components/FullscreenLoadingIndicator'; import Image from '@components/Image'; import RESIZE_MODES from '@components/Image/resizeModes'; import type {ImageOnLoadEvent} from '@components/Image/types'; import PressableWithoutFeedback from '@components/Pressable/PressableWithoutFeedback'; +import useNetwork from '@hooks/useNetwork'; import useStyleUtils from '@hooks/useStyleUtils'; import useThemeStyles from '@hooks/useThemeStyles'; import * as DeviceCapabilities from '@libs/DeviceCapabilities'; @@ -33,6 +35,7 @@ function ImageView({isAuthTokenRequired = false, url, fileName, onError}: ImageV const [imgHeight, setImgHeight] = useState(0); const [zoomScale, setZoomScale] = useState(0); const [zoomDelta, setZoomDelta] = useState(); + const {isOffline} = useNetwork(); const scrollableRef = useRef(null); const canUseTouchScreen = DeviceCapabilities.canUseTouchScreen(); @@ -210,7 +213,8 @@ function ImageView({isAuthTokenRequired = false, url, fileName, onError}: ImageV onLoad={imageLoad} onError={onError} /> - {(isLoading || zoomScale === 0) && } + {((isLoading && !isOffline) || (!isLoading && zoomScale === 0)) && } + {isLoading && } ); } @@ -243,7 +247,8 @@ function ImageView({isAuthTokenRequired = false, url, fileName, onError}: ImageV /> - {isLoading && } + {isLoading && !isOffline && } + {isLoading && } ); } diff --git a/src/components/ImageWithSizeCalculation.tsx b/src/components/ImageWithSizeCalculation.tsx index eac5c676370b..3d940103715d 100644 --- a/src/components/ImageWithSizeCalculation.tsx +++ b/src/components/ImageWithSizeCalculation.tsx @@ -6,6 +6,7 @@ import useNetwork from '@hooks/useNetwork'; import useThemeStyles from '@hooks/useThemeStyles'; import Log from '@libs/Log'; import CONST from '@src/CONST'; +import AttachmentOfflineIndicator from './AttachmentOfflineIndicator'; import FullscreenLoadingIndicator from './FullscreenLoadingIndicator'; import Image from './Image'; import RESIZE_MODES from './Image/resizeModes'; @@ -108,7 +109,8 @@ function ImageWithSizeCalculation({url, style, onMeasure, onLoadFailure, isAuthT onLoad={imageLoadedSuccessfully} objectPosition={objectPosition} /> - {isLoading && !isImageCached && } + {isLoading && !isImageCached && !isOffline && } + {isLoading && !isImageCached && } ); } diff --git a/src/components/Lightbox/index.tsx b/src/components/Lightbox/index.tsx index 86a52c2baf6c..0be0171eaa9a 100644 --- a/src/components/Lightbox/index.tsx +++ b/src/components/Lightbox/index.tsx @@ -2,12 +2,14 @@ import React, {useCallback, useContext, useEffect, useMemo, useState} from 'reac import type {LayoutChangeEvent, StyleProp, ViewStyle} from 'react-native'; import {ActivityIndicator, PixelRatio, StyleSheet, View} from 'react-native'; import {useSharedValue} from 'react-native-reanimated'; +import AttachmentOfflineIndicator from '@components/AttachmentOfflineIndicator'; import AttachmentCarouselPagerContext from '@components/Attachments/AttachmentCarousel/Pager/AttachmentCarouselPagerContext'; import Image from '@components/Image'; import type {ImageOnLoadEvent} from '@components/Image/types'; import MultiGestureCanvas, {DEFAULT_ZOOM_RANGE} from '@components/MultiGestureCanvas'; import type {CanvasSize, ContentSize, OnScaleChangedCallback, ZoomRange} from '@components/MultiGestureCanvas/types'; import {getCanvasFitScale} from '@components/MultiGestureCanvas/utils'; +import useNetwork from '@hooks/useNetwork'; import useStyleUtils from '@hooks/useStyleUtils'; import useThemeStyles from '@hooks/useThemeStyles'; import NUMBER_OF_CONCURRENT_LIGHTBOXES from './numberOfConcurrentLightboxes'; @@ -47,6 +49,7 @@ function Lightbox({isAuthTokenRequired = false, uri, onScaleChanged: onScaleChan * we need to create a shared value that can be used in the render function. */ const isPagerScrollingFallback = useSharedValue(false); + const {isOffline} = useNetwork(); const attachmentCarouselPagerContext = useContext(AttachmentCarouselPagerContext); const { @@ -219,8 +222,8 @@ function Lightbox({isAuthTokenRequired = false, uri, onScaleChanged: onScaleChan style={[contentSize ?? styles.invisibleImage]} isAuthTokenRequired={isAuthTokenRequired} onError={onError} - onLoad={updateContentSize} - onLoadEnd={() => { + onLoad={(e) => { + updateContentSize(e); setLightboxImageLoaded(true); }} /> @@ -236,19 +239,22 @@ function Lightbox({isAuthTokenRequired = false, uri, onScaleChanged: onScaleChan resizeMode="contain" style={[fallbackSize ?? styles.invisibleImage]} isAuthTokenRequired={isAuthTokenRequired} - onLoad={updateContentSize} - onLoadEnd={() => setFallbackImageLoaded(true)} + onLoad={(e) => { + updateContentSize(e); + setFallbackImageLoaded(true); + }} /> )} {/* Show activity indicator while the lightbox is still loading the image. */} - {isLoading && ( + {isLoading && !isOffline && ( )} + {isLoading && } )} diff --git a/src/components/MentionSuggestions.tsx b/src/components/MentionSuggestions.tsx index b11ae4f5ecd8..a787ac5ded56 100644 --- a/src/components/MentionSuggestions.tsx +++ b/src/components/MentionSuggestions.tsx @@ -83,7 +83,7 @@ function MentionSuggestions({prefix, mentions, highlightedMentionIndex = 0, onSe source={item.icons[0].source} size={isIcon ? CONST.AVATAR_SIZE.MENTION_ICON : CONST.AVATAR_SIZE.SMALLER} name={item.icons[0].name} - accountID={item.icons[0].id} + avatarID={item.icons[0].id} type={item.icons[0].type} fill={isIcon ? theme.success : undefined} fallbackIcon={item.icons[0].fallbackIcon} diff --git a/src/components/MenuItem.tsx b/src/components/MenuItem.tsx index 42de7e2fb7f4..ad06f9fcb78c 100644 --- a/src/components/MenuItem.tsx +++ b/src/components/MenuItem.tsx @@ -150,12 +150,12 @@ type MenuItemBaseProps = { /** Should the description be shown above the title (instead of the other way around) */ shouldShowDescriptionOnTop?: boolean; - /** Error to display below the title */ - error?: string; - /** Error to display at the bottom of the component */ errorText?: MaybePhraseKey; + /** Hint to display at the bottom of the component */ + hintText?: MaybePhraseKey; + /** A boolean flag that gives the icon a green fill if true */ success?: boolean; @@ -265,6 +265,9 @@ type MenuItemBaseProps = { /** Handles what to do when the item is focused */ onFocus?: () => void; + + /** Optional account id if it's user avatar or policy id if it's workspace avatar */ + avatarID?: number | string; }; type MenuItemProps = (IconProps | AvatarProps | NoIcon) & MenuItemBaseProps; @@ -304,8 +307,8 @@ function MenuItem( description, helperText, helperTextStyle, - error, errorText, + hintText, success = false, focused = false, disabled = false, @@ -342,6 +345,7 @@ function MenuItem( isPaneMenu = false, shouldPutLeftPaddingWhenNoIcon = false, onFocus, + avatarID, }: MenuItemProps, ref: PressableRef, ) { @@ -455,7 +459,6 @@ function MenuItem( style={({pressed}) => [ containerStyle, - errorText ? styles.pb5 : {}, combinedStyle, !interactive && styles.cursorDefault, StyleUtils.getButtonBackgroundColorStyle(getButtonState(focused || isHovered, pressed, success, disabled, interactive), true), @@ -473,206 +476,209 @@ function MenuItem( onFocus={onFocus} > {({pressed}) => ( - <> - - {!!label && isLabelHoverable && ( - - - {label} - - - )} - - {!!icon && Array.isArray(icon) && ( - + + + + {!!label && isLabelHoverable && ( + + + {label} + + )} - {!icon && shouldPutLeftPaddingWhenNoIcon && } - {icon && !Array.isArray(icon) && ( - - {typeof icon !== 'string' && iconType === CONST.ICON_TYPE_ICON && ( + + {!!icon && Array.isArray(icon) && ( + + )} + {!icon && shouldPutLeftPaddingWhenNoIcon && } + {icon && !Array.isArray(icon) && ( + + {typeof icon !== 'string' && iconType === CONST.ICON_TYPE_ICON && ( + + )} + {icon && iconType === CONST.ICON_TYPE_WORKSPACE && ( + + )} + {iconType === CONST.ICON_TYPE_AVATAR && ( + + )} + + )} + {secondaryIcon && ( + - )} - {icon && iconType === CONST.ICON_TYPE_WORKSPACE && ( - - )} - {iconType === CONST.ICON_TYPE_AVATAR && ( - - )} - - )} - {secondaryIcon && ( - - - - )} - - {!!description && shouldShowDescriptionOnTop && ( - - {description} - + )} - - {!!title && (shouldRenderAsHTML || (shouldParseTitle && !!html.length)) && ( - - - - )} - {!shouldRenderAsHTML && !shouldParseTitle && !!title && ( + + {!!description && shouldShowDescriptionOnTop && ( - {renderTitleContent()} + {description} )} - {shouldShowTitleIcon && titleIcon && ( - - - - )} - - {!!description && !shouldShowDescriptionOnTop && ( - - {description} - - )} - {!!error && ( - - {error} - - )} - {!!furtherDetails && ( - - {!!furtherDetailsIcon && ( - + + {!!title && (shouldRenderAsHTML || (shouldParseTitle && !!html.length)) && ( + + + + )} + {!shouldRenderAsHTML && !shouldParseTitle && !!title && ( + + {renderTitleContent()} + + )} + {shouldShowTitleIcon && titleIcon && ( + + + )} + + {!!description && !shouldShowDescriptionOnTop && ( - {furtherDetails} + {description} - - )} + )} + {!!furtherDetails && ( + + {!!furtherDetailsIcon && ( + + )} + + {furtherDetails} + + + )} + - - - {badgeText && ( - - )} - {/* Since subtitle can be of type number, we should allow 0 to be shown */} - {(subtitle === 0 || subtitle) && ( - - {subtitle} - - )} - {floatRightAvatars?.length > 0 && ( - - {shouldShowSubscriptRightAvatar ? ( - + {badgeText && ( + + )} + {/* Since subtitle can be of type number, we should allow 0 to be shown */} + {(subtitle === 0 || subtitle) && ( + + {subtitle} + + )} + {floatRightAvatars?.length > 0 && ( + + {shouldShowSubscriptRightAvatar ? ( + + ) : ( + + )} + + )} + {!!brickRoadIndicator && ( + + - ) : ( - + )} + {!title && !!rightLabel && !errorText && ( + + {rightLabel} + + )} + {shouldShowRightIcon && ( + + - )} - - )} - {!!brickRoadIndicator && ( - - - - )} - {!title && !!rightLabel && ( - - {rightLabel} - - )} - {shouldShowRightIcon && ( - - - - )} - {shouldShowRightComponent && rightComponent} - {shouldShowSelectedState && } + + )} + {shouldShowRightComponent && rightComponent} + {shouldShowSelectedState && } + {!!errorText && ( )} - + {!!hintText && ( + + )} + )} )} diff --git a/src/components/MoneyReportHeader.tsx b/src/components/MoneyReportHeader.tsx index e1a998f78cab..4eb3be871a8d 100644 --- a/src/components/MoneyReportHeader.tsx +++ b/src/components/MoneyReportHeader.tsx @@ -3,12 +3,15 @@ import {View} from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; import {withOnyx} from 'react-native-onyx'; import useLocalize from '@hooks/useLocalize'; +import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import useWindowDimensions from '@hooks/useWindowDimensions'; import * as CurrencyUtils from '@libs/CurrencyUtils'; import * as HeaderUtils from '@libs/HeaderUtils'; import * as ReportActionsUtils from '@libs/ReportActionsUtils'; import * as ReportUtils from '@libs/ReportUtils'; +import * as TransactionUtils from '@libs/TransactionUtils'; +import variables from '@styles/variables'; import * as IOU from '@userActions/IOU'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -19,8 +22,10 @@ import {isEmptyObject} from '@src/types/utils/EmptyObject'; import Button from './Button'; import ConfirmModal from './ConfirmModal'; import HeaderWithBackButton from './HeaderWithBackButton'; +import Icon from './Icon'; import * as Expensicons from './Icon/Expensicons'; import MoneyReportHeaderStatusBar from './MoneyReportHeaderStatusBar'; +import MoneyRequestHeaderStatusBar from './MoneyRequestHeaderStatusBar'; import ProcessMoneyReportHoldMenu from './ProcessMoneyReportHoldMenu'; import SettlementButton from './SettlementButton'; @@ -71,6 +76,7 @@ function MoneyReportHeader({ onBackButtonPress, }: MoneyReportHeaderProps) { const styles = useThemeStyles(); + const theme = useTheme(); const [isDeleteRequestModalVisible, setIsDeleteRequestModalVisible] = useState(false); const {translate} = useLocalize(); const {windowWidth} = useWindowDimensions(); @@ -98,6 +104,9 @@ function MoneyReportHeader({ const isDraft = ReportUtils.isOpenExpenseReport(moneyRequestReport); const [isConfirmModalVisible, setIsConfirmModalVisible] = useState(false); + const transactionIDs = TransactionUtils.getAllReportTransactions(moneyRequestReport?.reportID).map((transaction) => transaction.transactionID); + const allHavePendingRTERViolation = TransactionUtils.allHavePendingRTERViolation(transactionIDs); + const cancelPayment = useCallback(() => { if (!chatReport) { return; @@ -112,12 +121,12 @@ function MoneyReportHeader({ const shouldDisableApproveButton = shouldShowApproveButton && !ReportUtils.isAllowedToApproveExpenseReport(moneyRequestReport); - const shouldShowSettlementButton = !ReportUtils.isInvoiceReport(moneyRequestReport) && (shouldShowPayButton || shouldShowApproveButton); + const shouldShowSettlementButton = !ReportUtils.isInvoiceReport(moneyRequestReport) && (shouldShowPayButton || shouldShowApproveButton) && !allHavePendingRTERViolation; - const shouldShowSubmitButton = isDraft && reimbursableSpend !== 0; + const shouldShowSubmitButton = isDraft && reimbursableSpend !== 0 && !allHavePendingRTERViolation; const shouldDisableSubmitButton = shouldShowSubmitButton && !ReportUtils.isAllowedToSubmitDraftExpenseReport(moneyRequestReport); const isFromPaidPolicy = policyType === CONST.POLICY.TYPE.TEAM || policyType === CONST.POLICY.TYPE.CORPORATE; - const shouldShowNextStep = !ReportUtils.isClosedExpenseReportWithNoExpenses(moneyRequestReport) && isFromPaidPolicy && !!nextStep?.message?.length; + const shouldShowNextStep = !ReportUtils.isClosedExpenseReportWithNoExpenses(moneyRequestReport) && isFromPaidPolicy && !!nextStep?.message?.length && !allHavePendingRTERViolation; const shouldShowAnyButton = shouldShowSettlementButton || shouldShowApproveButton || shouldShowSubmitButton || shouldShowNextStep; const bankAccountRoute = ReportUtils.getBankAccountRoute(chatReport); const formattedAmount = CurrencyUtils.convertToDisplayString(reimbursableSpend, moneyRequestReport.currency); @@ -203,7 +212,7 @@ function MoneyReportHeader({ shouldShowBackButton={shouldUseNarrowLayout} onBackButtonPress={onBackButtonPress} // Shows border if no buttons or next steps are showing below the header - shouldShowBorderBottom={!(shouldShowAnyButton && shouldUseNarrowLayout) && !(shouldShowNextStep && !shouldUseNarrowLayout)} + shouldShowBorderBottom={!(shouldShowAnyButton && shouldUseNarrowLayout) && !(shouldShowNextStep && !shouldUseNarrowLayout) && !allHavePendingRTERViolation} shouldShowThreeDotsButton threeDotsMenuItems={threeDotsMenuItems} threeDotsAnchorPosition={styles.threeDotsPopoverOffsetNoCloseButton(windowWidth)} @@ -241,6 +250,20 @@ function MoneyReportHeader({ )} + {allHavePendingRTERViolation && ( + + } + description={translate('iou.pendingMatchWithCreditCardDescription')} + shouldShowBorderBottom + /> + )} {shouldShowSettlementButton && shouldUseNarrowLayout && ( diff --git a/src/components/MoneyRequestConfirmationList.tsx b/src/components/MoneyRequestConfirmationList.tsx index 2fdd79b1a71c..5ffd9beda6fe 100755 --- a/src/components/MoneyRequestConfirmationList.tsx +++ b/src/components/MoneyRequestConfirmationList.tsx @@ -375,7 +375,7 @@ function MoneyRequestConfirmationList({ const amountInSmallestCurrencyUnits = CurrencyUtils.convertToBackendAmount(Number.parseFloat(taxAmount)); if (transaction?.taxAmount && previousTransactionAmount === transaction?.amount && previousTransactionCurrency === transaction?.currency) { - return IOU.setMoneyRequestTaxAmount(transaction?.transactionID, transaction?.taxAmount, true); + return IOU.setMoneyRequestTaxAmount(transactionID, transaction?.taxAmount ?? 0, true); } IOU.setMoneyRequestTaxAmount(transactionID, amountInSmallestCurrencyUnits, true); @@ -506,10 +506,9 @@ function MoneyRequestConfirmationList({ hideCurrencySymbol formatAmountOnBlur prefixContainerStyle={[styles.pv0]} - inputStyle={ - [styles.optionRowAmountInput, StyleUtils.getPaddingLeft(StyleUtils.getCharacterPadding(currencySymbol ?? '') + styles.pl1.paddingLeft), amountWidth] as TextStyle[] - } + inputStyle={[styles.optionRowAmountInput, amountWidth] as TextStyle[]} containerStyle={[styles.textInputContainer]} + touchableInputWrapperStyle={[styles.ml3]} onAmountChange={(value: string) => onSplitShareChange(participantOption.accountID ?? 0, Number(value))} maxLength={formattedTotalAmount.length} /> @@ -529,8 +528,8 @@ function MoneyRequestConfirmationList({ styles.textLabel, styles.pv0, styles.optionRowAmountInput, - styles.pl1.paddingLeft, styles.textInputContainer, + styles.ml3, transaction?.comment?.splits, transaction?.splitShares, onSplitShareChange, @@ -599,7 +598,7 @@ function MoneyRequestConfirmationList({ const formattedSelectedParticipants = selectedParticipants.map((participant) => ({ ...participant, isSelected: false, - isDisabled: !participant.isPolicyExpenseChat && !participant.isSelfDM && ReportUtils.isOptimisticPersonalDetail(participant.accountID ?? -1), + isDisabled: !participant.isInvoiceRoom && !participant.isPolicyExpenseChat && !participant.isSelfDM && ReportUtils.isOptimisticPersonalDetail(participant.accountID ?? -1), })); options.push({ title: translate('common.to'), @@ -849,7 +848,7 @@ function MoneyRequestConfirmationList({ titleStyle={styles.moneyRequestConfirmationAmount} disabled={didConfirm} brickRoadIndicator={shouldDisplayFieldError && TransactionUtils.isAmountMissing(transaction ?? null) ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined} - error={shouldDisplayFieldError && TransactionUtils.isAmountMissing(transaction ?? null) ? translate('common.error.enterAmount') : ''} + errorText={shouldDisplayFieldError && TransactionUtils.isAmountMissing(transaction ?? null) ? translate('common.error.enterAmount') : ''} /> ), shouldShow: shouldShowSmartScanFields, @@ -945,8 +944,8 @@ function MoneyRequestConfirmationList({ disabled={didConfirm} interactive={!isReadOnly} brickRoadIndicator={shouldDisplayMerchantError ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined} - error={shouldDisplayMerchantError ? translate('common.error.fieldRequired') : ''} - rightLabel={isMerchantRequired ? translate('common.required') : ''} + errorText={shouldDisplayMerchantError ? translate('common.error.fieldRequired') : ''} + rightLabel={isMerchantRequired && !shouldDisplayMerchantError ? translate('common.required') : ''} /> ), shouldShow: shouldShowMerchant, @@ -968,7 +967,7 @@ function MoneyRequestConfirmationList({ disabled={didConfirm} interactive={!isReadOnly} brickRoadIndicator={shouldDisplayFieldError && TransactionUtils.isCreatedMissing(transaction) ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined} - error={shouldDisplayFieldError && TransactionUtils.isCreatedMissing(transaction) ? translate('common.error.enterDate') : ''} + errorText={shouldDisplayFieldError && TransactionUtils.isCreatedMissing(transaction) ? translate('common.error.enterDate') : ''} /> ), shouldShow: shouldShowDate, @@ -1134,6 +1133,7 @@ function MoneyRequestConfirmationList({ {isTypeInvoice && ( )} {isDistanceRequest && ( @@ -1208,6 +1208,7 @@ function MoneyRequestConfirmationList({ reportID, senderWorkspace?.avatarURL, senderWorkspace?.name, + senderWorkspace?.id, shouldShowAllFields, styles.confirmationListMapItem, styles.flex1, diff --git a/src/components/MoneyRequestHeader.tsx b/src/components/MoneyRequestHeader.tsx index 6680ac88dfe9..7c122f120e8a 100644 --- a/src/components/MoneyRequestHeader.tsx +++ b/src/components/MoneyRequestHeader.tsx @@ -1,7 +1,8 @@ +import type {ReactNode} from 'react'; import React, {useCallback, useEffect, useState} from 'react'; import {View} from 'react-native'; import {withOnyx} from 'react-native-onyx'; -import type {OnyxEntry} from 'react-native-onyx'; +import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; import useLocalize from '@hooks/useLocalize'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -16,12 +17,14 @@ import * as IOU from '@userActions/IOU'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; -import type {Policy, Report, ReportAction, ReportActions, Session, Transaction} from '@src/types/onyx'; +import type {Policy, Report, ReportAction, ReportActions, Session, Transaction, TransactionViolations} from '@src/types/onyx'; import type {OriginalMessageIOU} from '@src/types/onyx/OriginalMessage'; +import type IconAsset from '@src/types/utils/IconAsset'; import ConfirmModal from './ConfirmModal'; import HeaderWithBackButton from './HeaderWithBackButton'; import Icon from './Icon'; import * as Expensicons from './Icon/Expensicons'; +import type {MoneyRequestHeaderStatusBarProps} from './MoneyRequestHeaderStatusBar'; import MoneyRequestHeaderStatusBar from './MoneyRequestHeaderStatusBar'; import ProcessMoneyRequestHoldMenu from './ProcessMoneyRequestHoldMenu'; @@ -35,6 +38,9 @@ type MoneyRequestHeaderOnyxProps = { /** All the data for the transaction */ transaction: OnyxEntry; + /** The violations of the transaction */ + transactionViolations: OnyxCollection; + /** All report actions */ // eslint-disable-next-line react/no-unused-prop-types parentReportActions: OnyxEntry; @@ -65,6 +71,7 @@ function MoneyRequestHeader({ parentReport, report, parentReportAction, + transactionViolations, transaction, shownHoldUseExplanation = false, policy, @@ -101,7 +108,6 @@ function MoneyRequestHeader({ }, [parentReport?.reportID, parentReportAction, setIsDeleteModalVisible]); const isScanning = TransactionUtils.hasReceipt(transaction) && TransactionUtils.isReceiptBeingScanned(transaction); - const isPending = TransactionUtils.isExpensifyCardTransaction(transaction) && TransactionUtils.isPending(transaction); const isDeletedParentAction = ReportActionsUtils.isDeletedAction(parentReportAction); const canHoldOrUnholdRequest = !isSettled && !isApproved && !isDeletedParentAction; @@ -120,6 +126,33 @@ function MoneyRequestHeader({ } }; + const getStatusIcon: (src: IconAsset) => ReactNode = (src) => ( + + ); + + const getStatusBarProps: () => MoneyRequestHeaderStatusBarProps | undefined = () => { + if (isOnHold) { + return {title: translate('iou.hold'), description: translate('iou.expenseOnHold'), danger: true, shouldShowBorderBottom: true}; + } + + if (TransactionUtils.isExpensifyCardTransaction(transaction) && TransactionUtils.isPending(transaction)) { + return {title: getStatusIcon(Expensicons.CreditCardHourglass), description: translate('iou.transactionPendingDescription'), shouldShowBorderBottom: true}; + } + if (isScanning) { + return {title: getStatusIcon(Expensicons.ReceiptScan), description: translate('iou.receiptScanInProgressDescription'), shouldShowBorderBottom: true}; + } + if (TransactionUtils.hasPendingRTERViolation(TransactionUtils.getTransactionViolations(transaction?.transactionID ?? '', transactionViolations))) { + return {title: getStatusIcon(Expensicons.Hourglass), description: translate('iou.pendingMatchWithCreditCardDescription'), shouldShowBorderBottom: true}; + } + }; + + const statusBarProps = getStatusBarProps(); + useEffect(() => { if (canDeleteRequest) { return; @@ -144,7 +177,7 @@ function MoneyRequestHeader({ if (!isOnHold && (isRequestIOU || canModifyStatus) && !isScanning) { threeDotsMenuItems.push({ icon: Expensicons.Stopwatch, - text: translate('iou.holdExpense'), + text: translate('iou.hold'), onSelected: () => changeMoneyRequestStatus(), }); } @@ -184,7 +217,7 @@ function MoneyRequestHeader({ <> - {isPending && ( - - } - description={translate('iou.transactionPendingDescription')} - shouldShowBorderBottom={!isScanning} - /> - )} - {isScanning && ( + {statusBarProps && ( - } - description={translate('iou.receiptScanInProgressDescription')} - shouldShowBorderBottom - /> - )} - {isOnHold && ( - )} @@ -259,7 +264,7 @@ function MoneyRequestHeader({ MoneyRequestHeader.displayName = 'MoneyRequestHeader'; -const MoneyRequestHeaderWithTransaction = withOnyx>({ +const MoneyRequestHeaderWithTransaction = withOnyx>({ transaction: { key: ({report, parentReportActions}) => { const parentReportAction = (report.parentReportActionID && parentReportActions ? parentReportActions[report.parentReportActionID] : {}) as ReportAction & OriginalMessageIOU; @@ -270,9 +275,15 @@ const MoneyRequestHeaderWithTransaction = withOnyx, Omit>({ +export default withOnyx< + Omit, + Omit +>({ session: { key: ONYXKEYS.SESSION, }, diff --git a/src/components/MoneyRequestHeaderStatusBar.tsx b/src/components/MoneyRequestHeaderStatusBar.tsx index b7bba0223656..4ee3079d5f1f 100644 --- a/src/components/MoneyRequestHeaderStatusBar.tsx +++ b/src/components/MoneyRequestHeaderStatusBar.tsx @@ -57,3 +57,5 @@ function MoneyRequestHeaderStatusBar({title, description, shouldShowBorderBottom MoneyRequestHeaderStatusBar.displayName = 'MoneyRequestHeaderStatusBar'; export default MoneyRequestHeaderStatusBar; + +export type {MoneyRequestHeaderStatusBarProps}; diff --git a/src/components/MultipleAvatars.tsx b/src/components/MultipleAvatars.tsx index d5fcf6607179..601b8f7edfed 100644 --- a/src/components/MultipleAvatars.tsx +++ b/src/components/MultipleAvatars.tsx @@ -156,7 +156,7 @@ function MultipleAvatars({ size={size} fill={icons[0].fill} name={icons[0].name} - accountID={icons[0].id} + avatarID={icons[0].id} type={icons[0].type} fallbackIcon={icons[0].fallbackIcon} /> @@ -206,7 +206,7 @@ function MultipleAvatars({ source={icon.source ?? fallbackIcon} size={size} name={icon.name} - accountID={icon.id} + avatarID={icon.id} type={icon.type} fallbackIcon={icon.fallbackIcon} /> @@ -265,7 +265,7 @@ function MultipleAvatars({ imageStyles={[singleAvatarStyle]} name={icons[0].name} type={icons[0].type} - accountID={icons[0].id} + avatarID={icons[0].id} fallbackIcon={icons[0].fallbackIcon} /> @@ -285,7 +285,7 @@ function MultipleAvatars({ size={avatarSize} imageStyles={[singleAvatarStyle]} name={icons[1].name} - accountID={icons[1].id} + avatarID={icons[1].id} type={icons[1].type} fallbackIcon={icons[1].fallbackIcon} /> diff --git a/src/components/OfflineIndicator.tsx b/src/components/OfflineIndicator.tsx index 1a61b6622783..26a9dd740e24 100644 --- a/src/components/OfflineIndicator.tsx +++ b/src/components/OfflineIndicator.tsx @@ -7,9 +7,11 @@ import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import useWindowDimensions from '@hooks/useWindowDimensions'; import variables from '@styles/variables'; +import CONST from '@src/CONST'; import Icon from './Icon'; import * as Expensicons from './Icon/Expensicons'; import Text from './Text'; +import TextLink from './TextLink'; type OfflineIndicatorProps = { /** Optional styles for container element that will override the default styling for the offline indicator */ @@ -23,7 +25,7 @@ function OfflineIndicator({style, containerStyles}: OfflineIndicatorProps) { const theme = useTheme(); const styles = useThemeStyles(); const {translate} = useLocalize(); - const {isOffline} = useNetwork(); + const {isOffline, isBackendReachable} = useNetwork(); const {isSmallScreenWidth} = useWindowDimensions(); const computedStyles = useMemo((): StyleProp => { @@ -34,7 +36,7 @@ function OfflineIndicator({style, containerStyles}: OfflineIndicatorProps) { return isSmallScreenWidth ? styles.offlineIndicatorMobile : styles.offlineIndicator; }, [containerStyles, isSmallScreenWidth, styles.offlineIndicatorMobile, styles.offlineIndicator]); - if (!isOffline) { + if (!isOffline && isBackendReachable) { return null; } @@ -46,7 +48,22 @@ function OfflineIndicator({style, containerStyles}: OfflineIndicatorProps) { width={variables.iconSizeSmall} height={variables.iconSizeSmall} /> - {translate('common.youAppearToBeOffline')} + + {isOffline ? ( + translate('common.youAppearToBeOffline') + ) : ( + <> + {translate('common.weMightHaveProblem')} + + {new URL(CONST.STATUS_EXPENSIFY_URL).host} + + . + + )} + ); } diff --git a/src/components/OptionListContextProvider.tsx b/src/components/OptionListContextProvider.tsx index 4eef9e93f188..16bc9aef21eb 100644 --- a/src/components/OptionListContextProvider.tsx +++ b/src/components/OptionListContextProvider.tsx @@ -64,22 +64,11 @@ function OptionsListContextProvider({reports, children}: OptionsListProviderProp return; } - const lastUpdatedReport = ReportUtils.getLastUpdatedReport(); - - if (!lastUpdatedReport) { - return; - } - - const newOption = OptionsListUtils.createOptionFromReport(lastUpdatedReport, personalDetails); - const replaceIndex = options.reports.findIndex((option) => option.reportID === lastUpdatedReport.reportID); - - if (replaceIndex === -1) { - return; - } + const newReports = OptionsListUtils.createOptionList({}, reports).reports; setOptions((prevOptions) => { const newOptions = {...prevOptions}; - newOptions.reports[replaceIndex] = newOption; + newOptions.reports = newReports; return newOptions; }); // eslint-disable-next-line react-hooks/exhaustive-deps @@ -126,16 +115,16 @@ function OptionsListContextProvider({reports, children}: OptionsListProviderProp newReportOption: OptionsListUtils.SearchOption; }> = []; - Object.keys(personalDetails).forEach((accoutID) => { - const prevPersonalDetail = prevPersonalDetails?.[accoutID]; - const personalDetail = personalDetails?.[accoutID]; + Object.keys(personalDetails).forEach((accountID) => { + const prevPersonalDetail = prevPersonalDetails?.[accountID]; + const personalDetail = personalDetails?.[accountID]; if (isEqualPersonalDetail(prevPersonalDetail, personalDetail)) { return; } Object.values(reports ?? {}) - .filter((report) => Boolean(report?.participantAccountIDs?.includes(Number(accoutID))) || (ReportUtils.isSelfDM(report) && report?.ownerAccountID === Number(accoutID))) + .filter((report) => Boolean(Object.keys(report?.participants ?? {}).includes(accountID)) || (ReportUtils.isSelfDM(report) && report?.ownerAccountID === Number(accountID))) .forEach((report) => { if (!report) { return; diff --git a/src/components/OptionRow.tsx b/src/components/OptionRow.tsx index d00120a594d8..029bd57cd876 100644 --- a/src/components/OptionRow.tsx +++ b/src/components/OptionRow.tsx @@ -145,10 +145,10 @@ function OptionRow({ const hoveredStyle = hoverStyle ? flattenHoverStyle : styles.sidebarLinkHover; const hoveredBackgroundColor = hoveredStyle?.backgroundColor ? (hoveredStyle.backgroundColor as string) : backgroundColor; const focusedBackgroundColor = styles.sidebarLinkActive.backgroundColor; - const isMultipleParticipant = (option.participantsList?.length ?? 0) > 1; + const shouldUseShortFormInTooltip = (option.participantsList?.length ?? 0) > 1; // 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((option.participantsList ?? (option.accountID ? [option] : [])).slice(0, 10), isMultipleParticipant); + const displayNamesWithTooltips = ReportUtils.getDisplayNamesWithTooltips((option.participantsList ?? (option.accountID ? [option] : [])).slice(0, 10), shouldUseShortFormInTooltip); let subscriptColor = theme.appBG; if (optionIsFocused) { subscriptColor = focusedBackgroundColor; diff --git a/src/components/ReportActionItem/MoneyRequestPreview/MoneyRequestPreviewContent.tsx b/src/components/ReportActionItem/MoneyRequestPreview/MoneyRequestPreviewContent.tsx index 45893de809df..4ff318bc3c47 100644 --- a/src/components/ReportActionItem/MoneyRequestPreview/MoneyRequestPreviewContent.tsx +++ b/src/components/ReportActionItem/MoneyRequestPreview/MoneyRequestPreviewContent.tsx @@ -36,7 +36,7 @@ import CONST from '@src/CONST'; import type {IOUMessage} from '@src/types/onyx/OriginalMessage'; import type {EmptyObject} from '@src/types/utils/EmptyObject'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; -import type {MoneyRequestPreviewProps} from './types'; +import type {MoneyRequestPreviewProps, PendingMessageProps} from './types'; function MoneyRequestPreviewContent({ iouReport, @@ -84,7 +84,6 @@ function MoneyRequestPreviewContent({ const requestMerchant = truncate(merchant, {length: CONST.REQUEST_PREVIEW.MAX_LENGTH}); const hasReceipt = TransactionUtils.hasReceipt(transaction); const isScanning = hasReceipt && TransactionUtils.isReceiptBeingScanned(transaction); - const isPending = TransactionUtils.isPending(transaction); const isOnHold = TransactionUtils.isOnHold(transaction); const isSettlementOrApprovalPartial = Boolean(iouReport?.pendingFields?.partial); const isPartialHold = isSettlementOrApprovalPartial && isOnHold; @@ -184,6 +183,21 @@ function MoneyRequestPreviewContent({ return message; }; + const getPendingMessageProps: () => PendingMessageProps = () => { + if (isScanning) { + return {shouldShow: true, messageIcon: ReceiptScan, messageDescription: translate('iou.receiptScanInProgress')}; + } + if (TransactionUtils.isPending(transaction)) { + return {shouldShow: true, messageIcon: Expensicons.CreditCardHourglass, messageDescription: translate('iou.transactionPending')}; + } + if (TransactionUtils.hasPendingUI(transaction, TransactionUtils.getTransactionViolations(transaction?.transactionID ?? '', transactionViolations))) { + return {shouldShow: true, messageIcon: Expensicons.Hourglass, messageDescription: translate('iou.pendingMatchWithCreditCard')}; + } + return {shouldShow: false}; + }; + + const pendingMessageProps = getPendingMessageProps(); + const getDisplayAmountText = (): string => { if (isScanning) { return translate('iou.receiptScanning'); @@ -312,26 +326,15 @@ function MoneyRequestPreviewContent({ )} - {isScanning && ( - - - {translate('iou.receiptScanInProgress')} - - )} - {isPending && ( + {pendingMessageProps.shouldShow && ( - {translate('iou.transactionPending')} + {pendingMessageProps.messageDescription} )} diff --git a/src/components/ReportActionItem/MoneyRequestPreview/types.ts b/src/components/ReportActionItem/MoneyRequestPreview/types.ts index 0e3eb37ce6e3..9dcea80fdc05 100644 --- a/src/components/ReportActionItem/MoneyRequestPreview/types.ts +++ b/src/components/ReportActionItem/MoneyRequestPreview/types.ts @@ -2,6 +2,7 @@ import type {GestureResponderEvent, StyleProp, ViewStyle} from 'react-native'; import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; import type {ContextMenuAnchor} from '@pages/home/report/ContextMenu/ReportActionContextMenu'; import type * as OnyxTypes from '@src/types/onyx'; +import type IconAsset from '@src/types/utils/IconAsset'; type MoneyRequestPreviewOnyxProps = { /** All of the personal details for everyone */ @@ -71,4 +72,19 @@ type MoneyRequestPreviewProps = MoneyRequestPreviewOnyxProps & { isWhisper?: boolean; }; -export type {MoneyRequestPreviewProps, MoneyRequestPreviewOnyxProps}; +type NoPendingProps = {shouldShow: false}; + +type PendingProps = { + /** Whether to show the pending message or not */ + shouldShow: true; + + /** The icon to be displayed if a request is pending */ + messageIcon: IconAsset; + + /** The description to be displayed if a request is pending */ + messageDescription: string; +}; + +type PendingMessageProps = PendingProps | NoPendingProps; + +export type {MoneyRequestPreviewProps, MoneyRequestPreviewOnyxProps, PendingMessageProps}; diff --git a/src/components/ReportActionItem/MoneyRequestView.tsx b/src/components/ReportActionItem/MoneyRequestView.tsx index 361bc0b863c2..013e8e8e47e7 100644 --- a/src/components/ReportActionItem/MoneyRequestView.tsx +++ b/src/components/ReportActionItem/MoneyRequestView.tsx @@ -396,7 +396,7 @@ function MoneyRequestView({ shouldShowRightIcon={canEditAmount} onPress={() => Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_AMOUNT.getRoute(CONST.IOU.ACTION.EDIT, iouType, transaction?.transactionID ?? '', report.reportID))} brickRoadIndicator={getErrorForField('amount') ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined} - error={getErrorForField('amount')} + errorText={getErrorForField('amount')} /> @@ -410,7 +410,7 @@ function MoneyRequestView({ onPress={() => Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_DESCRIPTION.getRoute(CONST.IOU.ACTION.EDIT, iouType, transaction?.transactionID ?? '', report.reportID))} wrapperStyle={[styles.pv2, styles.taskDescriptionMenuItem]} brickRoadIndicator={getErrorForField('comment') ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined} - error={getErrorForField('comment')} + errorText={getErrorForField('comment')} numberOfLinesTitle={0} /> @@ -428,7 +428,7 @@ function MoneyRequestView({ Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_MERCHANT.getRoute(CONST.IOU.ACTION.EDIT, iouType, transaction?.transactionID ?? '', report.reportID)) } brickRoadIndicator={getErrorForField('merchant') ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined} - error={getErrorForField('merchant')} + errorText={getErrorForField('merchant')} /> )} @@ -441,7 +441,7 @@ function MoneyRequestView({ titleStyle={styles.flex1} onPress={() => Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_DATE.getRoute(CONST.IOU.ACTION.EDIT, iouType, transaction?.transactionID ?? '', report.reportID))} brickRoadIndicator={getErrorForField('date') ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined} - error={getErrorForField('date')} + errorText={getErrorForField('date')} /> {shouldShowCategory && ( @@ -456,7 +456,7 @@ function MoneyRequestView({ Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_CATEGORY.getRoute(CONST.IOU.ACTION.EDIT, iouType, transaction?.transactionID ?? '', report.reportID)) } brickRoadIndicator={getErrorForField('category') ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined} - error={getErrorForField('category')} + errorText={getErrorForField('category')} /> )} @@ -485,7 +485,7 @@ function MoneyRequestView({ ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined } - error={getErrorForField('tag', {tagListIndex: index, tagListName: name})} + errorText={getErrorForField('tag', {tagListIndex: index, tagListName: name})} /> ))} @@ -511,7 +511,7 @@ function MoneyRequestView({ Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_TAX_RATE.getRoute(CONST.IOU.ACTION.EDIT, iouType, transaction?.transactionID ?? '', report.reportID)) } brickRoadIndicator={getErrorForField('tax') ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined} - error={getErrorForField('tax')} + errorText={getErrorForField('tax')} /> )} diff --git a/src/components/ReportActionItem/ReportPreview.tsx b/src/components/ReportActionItem/ReportPreview.tsx index 9c5821329e1c..dae827cdb5c0 100644 --- a/src/components/ReportActionItem/ReportPreview.tsx +++ b/src/components/ReportActionItem/ReportPreview.tsx @@ -32,6 +32,7 @@ import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import type {Policy, Report, ReportAction, Transaction, TransactionViolations, UserWallet} from '@src/types/onyx'; import type {PaymentMethodType} from '@src/types/onyx/OriginalMessage'; +import type {PendingMessageProps} from './MoneyRequestPreview/types'; import ReportActionItemImages from './ReportActionItemImages'; type ReportPreviewOnyxProps = { @@ -140,6 +141,8 @@ function ReportPreview({ const hasErrors = hasMissingSmartscanFields || (canUseViolations && ReportUtils.hasViolations(iouReportID, transactionViolations)) || ReportUtils.hasActionsWithErrors(iouReportID); const lastThreeTransactionsWithReceipts = transactionsWithReceipts.slice(-3); const lastThreeReceipts = lastThreeTransactionsWithReceipts.map((transaction) => ReceiptUtils.getThumbnailAndImageURIs(transaction)); + const showRTERViolationMessage = + numberOfRequests === 1 && TransactionUtils.hasPendingUI(allTransactions[0], TransactionUtils.getTransactionViolations(allTransactions[0].transactionID, transactionViolations)); let formattedMerchant = numberOfRequests === 1 ? TransactionUtils.getMerchant(allTransactions[0]) : null; const formattedDescription = numberOfRequests === 1 ? TransactionUtils.getDescription(allTransactions[0]) : null; @@ -148,7 +151,7 @@ function ReportPreview({ formattedMerchant = null; } - const shouldShowSubmitButton = isOpenExpenseReport && reimbursableSpend !== 0; + const shouldShowSubmitButton = isOpenExpenseReport && reimbursableSpend !== 0 && !showRTERViolationMessage; const shouldDisableSubmitButton = shouldShowSubmitButton && !ReportUtils.isAllowedToSubmitDraftExpenseReport(iouReport); // The submit button should be success green colour only if the user is submitter and the policy does not have Scheduled Submit turned on @@ -210,7 +213,7 @@ function ReportPreview({ const shouldDisableApproveButton = shouldShowApproveButton && !ReportUtils.isAllowedToApproveExpenseReport(iouReport); - const shouldShowSettlementButton = !ReportUtils.isInvoiceReport(iouReport) && (shouldShowPayButton || shouldShowApproveButton); + const shouldShowSettlementButton = !ReportUtils.isInvoiceReport(iouReport) && (shouldShowPayButton || shouldShowApproveButton) && !showRTERViolationMessage; const shouldPromptUserToAddBankAccount = ReportUtils.hasMissingPaymentMethod(userWallet, iouReportID); const shouldShowRBR = !iouSettled && hasErrors; @@ -229,6 +232,21 @@ function ReportPreview({ const shouldShowScanningSubtitle = numberOfScanningReceipts === 1 && numberOfRequests === 1; const shouldShowPendingSubtitle = numberOfPendingRequests === 1 && numberOfRequests === 1; + const getPendingMessageProps: () => PendingMessageProps = () => { + if (shouldShowScanningSubtitle) { + return {shouldShow: true, messageIcon: Expensicons.ReceiptScan, messageDescription: translate('iou.receiptScanInProgress')}; + } + if (shouldShowPendingSubtitle) { + return {shouldShow: true, messageIcon: Expensicons.CreditCardHourglass, messageDescription: translate('iou.transactionPending')}; + } + if (showRTERViolationMessage) { + return {shouldShow: true, messageIcon: Expensicons.Hourglass, messageDescription: translate('iou.pendingMatchWithCreditCard')}; + } + return {shouldShow: false}; + }; + + const pendingMessageProps = getPendingMessageProps(); + const {supportText} = useMemo(() => { if (formattedMerchant) { return {supportText: formattedMerchant}; @@ -314,26 +332,15 @@ function ReportPreview({ )} - {shouldShowScanningSubtitle && ( - - - {translate('iou.receiptScanInProgress')} - - )} - {shouldShowPendingSubtitle && ( - + {pendingMessageProps.shouldShow && ( + - {translate('iou.transactionPending')} + {pendingMessageProps.messageDescription} )} diff --git a/src/components/ReportWelcomeText.tsx b/src/components/ReportWelcomeText.tsx index 83aefcd8aba9..cfbce4358eb5 100644 --- a/src/components/ReportWelcomeText.tsx +++ b/src/components/ReportWelcomeText.tsx @@ -1,7 +1,7 @@ import React, {useMemo} from 'react'; import {View} from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; -import {withOnyx} from 'react-native-onyx'; +import {useOnyx, withOnyx} from 'react-native-onyx'; import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; import Navigation from '@libs/Navigation/Navigation'; @@ -34,12 +34,16 @@ type ReportWelcomeTextProps = ReportWelcomeTextOnyxProps & { function ReportWelcomeText({report, policy, personalDetails}: ReportWelcomeTextProps) { const {translate} = useLocalize(); const styles = useThemeStyles(); + const [session] = useOnyx(ONYXKEYS.SESSION); const isPolicyExpenseChat = ReportUtils.isPolicyExpenseChat(report); const isChatRoom = ReportUtils.isChatRoom(report); const isSelfDM = ReportUtils.isSelfDM(report); const isInvoiceRoom = ReportUtils.isInvoiceRoom(report); + const isOneOnOneChat = ReportUtils.isOneOnOneChat(report); const isDefault = !(isChatRoom || isPolicyExpenseChat || isSelfDM || isInvoiceRoom); - const participantAccountIDs = report?.participantAccountIDs ?? []; + const participantAccountIDs = Object.keys(report?.participants ?? {}) + .map(Number) + .filter((accountID) => accountID !== session?.accountID || !isOneOnOneChat); const isMultipleParticipant = participantAccountIDs.length > 1; const displayNamesWithTooltips = ReportUtils.getDisplayNamesWithTooltips(OptionsListUtils.getPersonalDetailsForAccountIDs(participantAccountIDs, personalDetails), isMultipleParticipant); const isUserPolicyAdmin = PolicyUtils.isPolicyAdmin(policy); diff --git a/src/components/RoomHeaderAvatars.tsx b/src/components/RoomHeaderAvatars.tsx index bdb4a0ac78ab..fc3dda9668dd 100644 --- a/src/components/RoomHeaderAvatars.tsx +++ b/src/components/RoomHeaderAvatars.tsx @@ -47,7 +47,7 @@ function RoomHeaderAvatars({icons, reportID}: RoomHeaderAvatarsProps) { imageStyles={styles.avatarLarge} size={CONST.AVATAR_SIZE.LARGE} name={icons[0].name} - accountID={icons[0].id} + avatarID={icons[0].id} type={icons[0].type} fallbackIcon={icons[0].fallbackIcon} /> @@ -83,7 +83,7 @@ function RoomHeaderAvatars({icons, reportID}: RoomHeaderAvatarsProps) { size={CONST.AVATAR_SIZE.LARGE} containerStyles={[...iconStyle, StyleUtils.getAvatarBorderRadius(CONST.AVATAR_SIZE.LARGE_BORDERED, icon.type)]} name={icon.name} - accountID={icon.id} + avatarID={icon.id} type={icon.type} fallbackIcon={icon.fallbackIcon} /> diff --git a/src/components/Search.tsx b/src/components/Search.tsx index fbd352a798c6..a8e469da7d99 100644 --- a/src/components/Search.tsx +++ b/src/components/Search.tsx @@ -19,14 +19,15 @@ import TableListItemSkeleton from './Skeletons/TableListItemSkeleton'; type SearchProps = { query: string; + policyIDs?: string; }; -function Search({query}: SearchProps) { +function Search({query, policyIDs}: SearchProps) { const {isOffline} = useNetwork(); const styles = useThemeStyles(); useCustomBackHandler(); - const hash = SearchUtils.getQueryHash(query); + const hash = SearchUtils.getQueryHash(query, policyIDs); const [searchResults, searchResultsMeta] = useOnyx(`${ONYXKEYS.COLLECTION.SNAPSHOT}${hash}`); useEffect(() => { @@ -34,8 +35,9 @@ function Search({query}: SearchProps) { return; } - SearchActions.search(query); - }, [query, isOffline]); + SearchActions.search(hash, query, policyIDs); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [hash, isOffline]); const isLoading = (!isOffline && isLoadingOnyxValue(searchResultsMeta)) || searchResults?.data === undefined; const shouldShowEmptyState = !isLoading && isEmptyObject(searchResults?.data); @@ -65,11 +67,10 @@ function Search({query}: SearchProps) { const ListItem = SearchUtils.getListItem(type); const data = SearchUtils.getSections(searchResults?.data ?? {}, type); - const shouldShowMerchant = SearchUtils.getShouldShowMerchant(searchResults?.data ?? {}); return ( } + customListHeader={} ListItem={ListItem} sections={[{data, isDisabled: false}]} onSelectRow={(item) => { @@ -78,6 +79,7 @@ function Search({query}: SearchProps) { shouldPreventDefaultFocusOnSelectRow={!DeviceCapabilities.canUseTouchScreen()} listHeaderWrapperStyle={[styles.ph9, styles.pv3, styles.pb5]} containerStyle={[styles.pv0]} + showScrollIndicator={false} /> ); } diff --git a/src/components/SelectionList/BaseSelectionList.tsx b/src/components/SelectionList/BaseSelectionList.tsx index b42ad9ef3177..cb8d06097633 100644 --- a/src/components/SelectionList/BaseSelectionList.tsx +++ b/src/components/SelectionList/BaseSelectionList.tsx @@ -171,7 +171,7 @@ function BaseSelectionList( }, [canSelectMultiple, sections]); const [slicedSections, ShowMoreButtonInstance] = useMemo(() => { - let remainingOptionsLimit = CONST.MAX_OPTIONS_SELECTOR_PAGE_LENGTH * currentPage; + let remainingOptionsLimit = CONST.MAX_SELECTION_LIST_PAGE_LENGTH * currentPage; const processedSections = getSectionsWithIndexOffset( sections.map((section) => { const data = !isEmpty(section.data) && remainingOptionsLimit > 0 ? section.data.slice(0, remainingOptionsLimit) : []; @@ -184,11 +184,11 @@ function BaseSelectionList( }), ); - const shouldShowMoreButton = flattenedSections.allOptions.length > CONST.MAX_OPTIONS_SELECTOR_PAGE_LENGTH * currentPage; + const shouldShowMoreButton = flattenedSections.allOptions.length > CONST.MAX_SELECTION_LIST_PAGE_LENGTH * currentPage; const showMoreButton = shouldShowMoreButton ? ( @@ -229,7 +229,7 @@ function BaseSelectionList( // If `initiallyFocusedOptionKey` is not passed, we fall back to `-1`, to avoid showing the highlight on the first member const [focusedIndex, setFocusedIndex] = useArrowKeyFocusManager({ initialFocusedIndex: flattenedSections.allOptions.findIndex((option) => option.keyForList === initiallyFocusedOptionKey), - maxIndex: Math.min(flattenedSections.allOptions.length - 1, CONST.MAX_OPTIONS_SELECTOR_PAGE_LENGTH * currentPage - 1), + maxIndex: Math.min(flattenedSections.allOptions.length - 1, CONST.MAX_SELECTION_LIST_PAGE_LENGTH * currentPage - 1), disabledIndexes: flattenedSections.disabledOptionsIndexes, isActive: true, onFocusedIndexChange: (index: number) => { @@ -331,7 +331,7 @@ function BaseSelectionList( // we need to know the heights of all list items up-front in order to synchronously compute the layout of any given list item. // So be aware that if you adjust the content of the section header (for example, change the font size), you may need to adjust this explicit height as well. - {section.title} + {section.title} ); }; @@ -515,9 +515,9 @@ function BaseSelectionList( return ( {({safeAreaPaddingBottomStyle}) => ( - + {shouldShowTextInput && ( - + { innerTextInputRef.current = element as RNTextInput; diff --git a/src/components/SelectionList/SearchTableHeader.tsx b/src/components/SelectionList/SearchTableHeader.tsx index 02986f22dccc..ec0267d20c04 100644 --- a/src/components/SelectionList/SearchTableHeader.tsx +++ b/src/components/SelectionList/SearchTableHeader.tsx @@ -1,24 +1,30 @@ import React from 'react'; import {View} from 'react-native'; -import Text from '@components/Text'; import useLocalize from '@hooks/useLocalize'; import useStyleUtils from '@hooks/useStyleUtils'; import useThemeStyles from '@hooks/useThemeStyles'; import useWindowDimensions from '@hooks/useWindowDimensions'; +import * as SearchUtils from '@libs/SearchUtils'; import CONST from '@src/CONST'; +import type * as OnyxTypes from '@src/types/onyx'; +import SearchTableHeaderColumn from './SearchTableHeaderColumn'; type SearchTableHeaderProps = { - /** Whether we should show the merchant or description column */ - shouldShowMerchant: boolean; + data: OnyxTypes.SearchResults['data']; }; -function SearchTableHeader({shouldShowMerchant}: SearchTableHeaderProps) { +function SearchTableHeader({data}: SearchTableHeaderProps) { const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); const {isSmallScreenWidth, isMediumScreenWidth} = useWindowDimensions(); const {translate} = useLocalize(); const displayNarrowVersion = isMediumScreenWidth || isSmallScreenWidth; + const shouldShowCategoryColumn = SearchUtils.getShouldShowColumn(data, CONST.SEARCH_TABLE_COLUMNS.CATEGORY); + const shouldShowTagColumn = SearchUtils.getShouldShowColumn(data, CONST.SEARCH_TABLE_COLUMNS.TAG); + const shouldShowTaxColumn = SearchUtils.getShouldShowColumn(data, CONST.SEARCH_TABLE_COLUMNS.TAX_AMOUNT); + const shouldShowMerchant = SearchUtils.getShouldShowMerchant(data); + if (displayNarrowVersion) { return; } @@ -26,27 +32,50 @@ function SearchTableHeader({shouldShowMerchant}: SearchTableHeaderProps) { return ( - - {translate('common.date')} - - - {translate(shouldShowMerchant ? 'common.merchant' : 'common.description')} - - - {translate('common.from')} - - - {translate('common.to')} - - - {translate('common.total')} - - - {translate('common.type')} - - - {translate('common.action')} - + + + + + + + + + + ); diff --git a/src/components/SelectionList/SearchTableHeaderColumn.tsx b/src/components/SelectionList/SearchTableHeaderColumn.tsx new file mode 100644 index 000000000000..92b59f702cb1 --- /dev/null +++ b/src/components/SelectionList/SearchTableHeaderColumn.tsx @@ -0,0 +1,31 @@ +import React from 'react'; +import {View} from 'react-native'; +import type {StyleProp, TextStyle, ViewStyle} from 'react-native'; +import Text from '@components/Text'; +import useThemeStyles from '@hooks/useThemeStyles'; + +type SearchTableHeaderColumnProps = { + shouldShow?: boolean; + containerStyle?: StyleProp; + text: string; + textStyle?: StyleProp; +}; + +export default function SearchTableHeaderColumn({containerStyle, text, textStyle, shouldShow = true}: SearchTableHeaderColumnProps) { + const styles = useThemeStyles(); + + if (!shouldShow) { + return null; + } + + return ( + + + {text} + + + ); +} diff --git a/src/components/SelectionList/TextWithIconCell.tsx b/src/components/SelectionList/TextWithIconCell.tsx new file mode 100644 index 000000000000..bd51133a362f --- /dev/null +++ b/src/components/SelectionList/TextWithIconCell.tsx @@ -0,0 +1,45 @@ +import React from 'react'; +import {View} from 'react-native'; +import Icon from '@components/Icon'; +import Text from '@components/Text'; +import Tooltip from '@components/Tooltip'; +import useTheme from '@hooks/useTheme'; +import useThemeStyles from '@hooks/useThemeStyles'; +import type IconAsset from '@src/types/utils/IconAsset'; + +type TextWithIconCellProps = { + icon: IconAsset; + text?: string; + showTooltip: boolean; +}; + +export default function TextWithIconCell({icon, text, showTooltip}: TextWithIconCellProps) { + const styles = useThemeStyles(); + const theme = useTheme(); + + if (!text) { + return null; + } + + return ( + + + + + {text} + + + + ); +} diff --git a/src/components/SelectionList/TransactionListItem.tsx b/src/components/SelectionList/TransactionListItem.tsx index 0965ce6dabce..bb0918d7569b 100644 --- a/src/components/SelectionList/TransactionListItem.tsx +++ b/src/components/SelectionList/TransactionListItem.tsx @@ -17,8 +17,9 @@ import * as TransactionUtils from '@libs/TransactionUtils'; import variables from '@styles/variables'; import CONST from '@src/CONST'; import type {Transaction} from '@src/types/onyx'; -import type {SearchPersonalDetails, SearchPolicyDetails, SearchTransactionType} from '@src/types/onyx/SearchResults'; +import type {SearchAccountDetails, SearchTransactionType} from '@src/types/onyx/SearchResults'; import BaseListItem from './BaseListItem'; +import TextWithIconCell from './TextWithIconCell'; import type {ListItem, TransactionListItemProps, TransactionListItemType} from './types'; const getTypeIcon = (type?: SearchTransactionType) => { @@ -61,6 +62,7 @@ function TransactionListItem({ const isFromExpenseReport = transactionItem.reportType === CONST.REPORT.TYPE.EXPENSE; const date = TransactionUtils.getCreated(transactionItem as OnyxEntry, CONST.DATE.MONTH_DAY_ABBR_FORMAT); const amount = TransactionUtils.getAmount(transactionItem as OnyxEntry, isFromExpenseReport); + const taxAmount = TransactionUtils.getTaxAmount(transactionItem as OnyxEntry, isFromExpenseReport); const currency = TransactionUtils.getCurrency(transactionItem as OnyxEntry); const description = TransactionUtils.getDescription(transactionItem as OnyxEntry); const merchant = getMerchant(); @@ -82,7 +84,7 @@ function TransactionListItem({ /> ); - const userCell = (participant: SearchPersonalDetails & SearchPolicyDetails) => { + const userCell = (participant: SearchAccountDetails) => { const displayName = participant?.name ?? participant?.displayName ?? participant?.login; const avatarURL = participant?.avatarURL ?? participant?.avatar; const isWorkspace = participant?.avatarURL !== undefined; @@ -96,7 +98,7 @@ function TransactionListItem({ source={avatarURL} name={displayName} type={iconType} - accountID={isWorkspace ? participant?.id : participant?.accountID} + avatarID={isWorkspace ? participant?.id : participant?.accountID} /> ({ ); }; + const categoryCell = isLargeScreenWidth ? ( + + ) : ( + + ); + + const tagCell = isLargeScreenWidth ? ( + + ) : ( + + ); + + const taxCell = ( + + ); + const totalCell = ( ({ > {() => ( <> - + {userCell(transactionItem.from)} ({ height={variables.iconSizeXXSmall} fill={theme.icon} /> - {userCell(transactionItem.to)} + {userCell(transactionItem.to)} {actionCell} - {merchantCell} - + + {merchantCell} + + {categoryCell} + {tagCell} + + + {totalCell} {typeCell} @@ -216,7 +260,10 @@ function TransactionListItem({ {dateCell} {merchantCell} {userCell(transactionItem.from)} - {userCell(transactionItem.to)} + {userCell(transactionItem.to)} + {transactionItem.shouldShowCategory && {categoryCell}} + {transactionItem.shouldShowTag && {tagCell}} + {transactionItem.shouldShowTax && {taxCell}} {totalCell} {typeCell} {actionCell} diff --git a/src/components/SelectionList/types.ts b/src/components/SelectionList/types.ts index 8a2a1efdd030..79e47e4aa4d7 100644 --- a/src/components/SelectionList/types.ts +++ b/src/components/SelectionList/types.ts @@ -1,11 +1,10 @@ import type {MutableRefObject, ReactElement, ReactNode} from 'react'; import type {GestureResponderEvent, InputModeOptions, LayoutChangeEvent, SectionListData, StyleProp, TextInput, TextStyle, ViewStyle} from 'react-native'; -import type {ValueOf} from 'type-fest'; import type {MaybePhraseKey} from '@libs/Localize'; import type {BrickRoad} from '@libs/WorkspacesSettingsUtils'; import type CONST from '@src/CONST'; import type {Errors, Icon, PendingAction} from '@src/types/onyx/OnyxCommon'; -import type {SearchPersonalDetails, SearchPolicyDetails} from '@src/types/onyx/SearchResults'; +import type {SearchAccountDetails, SearchTransaction} from '@src/types/onyx/SearchResults'; import type {ReceiptErrors} from '@src/types/onyx/Transaction'; import type ChildrenProps from '@src/types/utils/ChildrenProps'; import type IconAsset from '@src/types/utils/IconAsset'; @@ -128,64 +127,26 @@ type ListItem = { brickRoadIndicator?: BrickRoad | '' | null; }; -type TransactionListItemType = ListItem & { - /** The ID of the transaction */ - transactionID: string; +type TransactionListItemType = ListItem & + SearchTransaction & { + /** The personal details of the user requesting money */ + from: SearchAccountDetails; - /** The transaction created date */ - created: string; + /** The personal details of the user paying the request */ + to: SearchAccountDetails; - /** The edited transaction created date */ - modifiedCreated: string; + /** Whether we should show the merchant column */ + shouldShowMerchant: boolean; - /** The transaction amount */ - amount: number; + /** Whether we should show the category column */ + shouldShowCategory: boolean; - /** The edited transaction amount */ - modifiedAmount: number; + /** Whether we should show the tag column */ + shouldShowTag: boolean; - /** The transaction currency */ - currency: string; - - /** The edited transaction currency */ - modifiedCurrency: string; - - /** The transaction merchant */ - merchant: string; - - /** The edited transaction merchant */ - modifiedMerchant: string; - - /** The receipt object */ - receipt?: {source?: string}; - - /** The personal details of the user requesting money */ - from: SearchPersonalDetails & SearchPolicyDetails; - - /** The personal details of the user paying the request */ - to: SearchPersonalDetails & SearchPolicyDetails; - - /** The transaction tag */ - tag: string; - - /** The transaction description */ - comment: {comment: string}; - - /** The transaction category */ - category: string; - - /** The type of request */ - type: ValueOf; - - /** The type of report the transaction is associated with */ - reportType: string; - - /** The ID of the policy the transaction is associated with */ - policyID: string; - - /** Whether we should show the merchant column */ - shouldShowMerchant: boolean; -}; + /** Whether we should show the tax column */ + shouldShowTax: boolean; + }; type ListItemProps = CommonListItemProps & { /** The section list item */ @@ -448,7 +409,7 @@ export type { SelectionListHandle, TableListItemProps, TransactionListItemProps, + TransactionListItemType, UserListItemProps, ValidListItem, - TransactionListItemType, }; diff --git a/src/components/StateSelector.tsx b/src/components/StateSelector.tsx index 18e6f45ed9e5..8cae007679ff 100644 --- a/src/components/StateSelector.tsx +++ b/src/components/StateSelector.tsx @@ -2,14 +2,14 @@ import {useIsFocused} from '@react-navigation/native'; import {CONST as COMMON_CONST} from 'expensify-common/lib/CONST'; import React, {useEffect, useRef} from 'react'; import type {ForwardedRef} from 'react'; -import {View} from 'react-native'; +import type {View} from 'react-native'; import useGeographicalStateFromRoute from '@hooks/useGeographicalStateFromRoute'; import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; import type {MaybePhraseKey} from '@libs/Localize'; import Navigation from '@libs/Navigation/Navigation'; +import CONST from '@src/CONST'; import ROUTES from '@src/ROUTES'; -import FormHelpMessage from './FormHelpMessage'; import type {MenuItemProps} from './MenuItem'; import MenuItemWithTopDescription from './MenuItemWithTopDescription'; @@ -79,26 +79,23 @@ function StateSelector( const descStyle = title.length === 0 ? styles.textNormal : null; return ( - - { - const activeRoute = Navigation.getActiveRoute(); - didOpenStateSelector.current = true; - Navigation.navigate(stateSelectorRoute.getRoute(stateCode, activeRoute, label)); - }} - wrapperStyle={wrapperStyle} - /> - - - - + { + const activeRoute = Navigation.getActiveRoute(); + didOpenStateSelector.current = true; + Navigation.navigate(stateSelectorRoute.getRoute(stateCode, activeRoute, label)); + }} + wrapperStyle={wrapperStyle} + /> ); } diff --git a/src/components/SubscriptAvatar.tsx b/src/components/SubscriptAvatar.tsx index cc36657826f6..46a88c54219c 100644 --- a/src/components/SubscriptAvatar.tsx +++ b/src/components/SubscriptAvatar.tsx @@ -82,7 +82,7 @@ function SubscriptAvatar({ source={mainAvatar?.source} size={size} name={mainAvatar?.name} - accountID={mainAvatar?.id} + avatarID={mainAvatar?.id} type={mainAvatar?.type} fallbackIcon={mainAvatar?.fallbackIcon} /> @@ -109,7 +109,7 @@ function SubscriptAvatar({ size={isSmall ? CONST.AVATAR_SIZE.SMALL_SUBSCRIPT : CONST.AVATAR_SIZE.SUBSCRIPT} fill={secondaryAvatar.fill} name={secondaryAvatar.name} - accountID={secondaryAvatar.id} + avatarID={secondaryAvatar.id} type={secondaryAvatar.type} fallbackIcon={secondaryAvatar.fallbackIcon} /> diff --git a/src/components/TextPicker/TextSelectorModal.tsx b/src/components/TextPicker/TextSelectorModal.tsx index 2abbd9abc8e6..a867a7030b54 100644 --- a/src/components/TextPicker/TextSelectorModal.tsx +++ b/src/components/TextPicker/TextSelectorModal.tsx @@ -1,11 +1,13 @@ -import React, {useState} from 'react'; -import {View} from 'react-native'; +import {useFocusEffect} from '@react-navigation/native'; +import React, {useCallback, useRef, useState} from 'react'; +import {Keyboard, View} from 'react-native'; import Button from '@components/Button'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import Modal from '@components/Modal'; import ScreenWrapper from '@components/ScreenWrapper'; import ScrollView from '@components/ScrollView'; import TextInput from '@components/TextInput'; +import type {BaseTextInputRef} from '@components/TextInput/BaseTextInput/types'; import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; import CONST from '@src/CONST'; @@ -19,6 +21,25 @@ function TextSelectorModal({value, description = '', onValueSelected, isVisible, const [currentValue, setValue] = useState(value); const paddingStyle = usePaddingStyle(); + const inputRef = useRef(null); + const focusTimeoutRef = useRef(null); + + useFocusEffect( + useCallback(() => { + focusTimeoutRef.current = setTimeout(() => { + if (inputRef.current && isVisible) { + inputRef.current.focus(); + } + return () => { + if (!focusTimeoutRef.current || !isVisible) { + return; + } + clearTimeout(focusTimeoutRef.current); + }; + }, CONST.ANIMATED_TRANSITION); + }, [isVisible]), + ); + return ( - + { + if (!ref) { + return; + } + inputRef.current = ref; + }} />