diff --git a/__mocks__/@react-native-async-storage/async-storage.js b/__mocks__/@react-native-async-storage/async-storage.js deleted file mode 100644 index 1051fa919c94..000000000000 --- a/__mocks__/@react-native-async-storage/async-storage.js +++ /dev/null @@ -1 +0,0 @@ -export {default} from '@react-native-async-storage/async-storage/jest/async-storage-mock'; diff --git a/__mocks__/@react-native-clipboard/clipboard.js b/__mocks__/@react-native-clipboard/clipboard.js new file mode 100644 index 000000000000..e56e290c3cc9 --- /dev/null +++ b/__mocks__/@react-native-clipboard/clipboard.js @@ -0,0 +1,3 @@ +import MockClipboard from '@react-native-clipboard/clipboard/jest/clipboard-mock'; + +export default MockClipboard; diff --git a/__mocks__/fileMock.js b/__mocks__/fileMock.js deleted file mode 100644 index 86059f362924..000000000000 --- a/__mocks__/fileMock.js +++ /dev/null @@ -1 +0,0 @@ -module.exports = 'test-file-stub'; diff --git a/__mocks__/fileMock.ts b/__mocks__/fileMock.ts new file mode 100644 index 000000000000..3592aaaaf26b --- /dev/null +++ b/__mocks__/fileMock.ts @@ -0,0 +1,3 @@ +const fileMock = 'test-file-stub'; + +export default fileMock; diff --git a/__mocks__/react-native-config.js b/__mocks__/react-native-config.js deleted file mode 100644 index 7c3900efa21e..000000000000 --- a/__mocks__/react-native-config.js +++ /dev/null @@ -1,6 +0,0 @@ -const path = require('path'); -const dotenv = require('dotenv'); - -const env = dotenv.config({path: path.resolve('./.env.example')}).parsed; - -export default env; diff --git a/__mocks__/react-native-config.ts b/__mocks__/react-native-config.ts new file mode 100644 index 000000000000..129d7c97ed43 --- /dev/null +++ b/__mocks__/react-native-config.ts @@ -0,0 +1,8 @@ +import dotenv from 'dotenv'; +import path from 'path'; + +type ReactNativeConfigMock = dotenv.DotenvParseOutput | undefined; + +const reactNativeConfigMock: ReactNativeConfigMock = dotenv.config({path: path.resolve('./.env.example')}).parsed; + +export default reactNativeConfigMock; diff --git a/__mocks__/react-native-document-picker.js b/__mocks__/react-native-document-picker.js new file mode 100644 index 000000000000..8cba2bc1eba4 --- /dev/null +++ b/__mocks__/react-native-document-picker.js @@ -0,0 +1,23 @@ +export default { + getConstants: jest.fn(), + pick: jest.fn(), + releaseSecureAccess: jest.fn(), + pickDirectory: jest.fn(), + + types: Object.freeze({ + allFiles: 'public.item', + audio: 'public.audio', + csv: 'public.comma-separated-values-text', + doc: 'com.microsoft.word.doc', + docx: 'org.openxmlformats.wordprocessingml.document', + images: 'public.image', + pdf: 'com.adobe.pdf', + plainText: 'public.plain-text', + ppt: 'com.microsoft.powerpoint.ppt', + pptx: 'org.openxmlformats.presentationml.presentation', + video: 'public.movie', + xls: 'com.microsoft.excel.xls', + xlsx: 'org.openxmlformats.spreadsheetml.sheet', + zip: 'public.zip-archive', + }), +}; diff --git a/__mocks__/react-native-image-picker.js b/__mocks__/react-native-image-picker.js deleted file mode 100644 index 7e4c29fa79ac..000000000000 --- a/__mocks__/react-native-image-picker.js +++ /dev/null @@ -1,5 +0,0 @@ -export default { - showImagePicker: jest.fn(), - launchCamera: jest.fn(), - launchImageLibrary: jest.fn(), -}; diff --git a/__mocks__/react-native-key-command.js b/__mocks__/react-native-key-command.js deleted file mode 100644 index 5bd02dfd121d..000000000000 --- a/__mocks__/react-native-key-command.js +++ /dev/null @@ -1,7 +0,0 @@ -const registerKeyCommands = () => {}; -const unregisterKeyCommands = () => {}; -const constants = {}; -const eventEmitter = () => {}; -const addListener = () => {}; - -export {registerKeyCommands, unregisterKeyCommands, constants, eventEmitter, addListener}; diff --git a/__mocks__/react-native-key-command.ts b/__mocks__/react-native-key-command.ts new file mode 100644 index 000000000000..288b7847684d --- /dev/null +++ b/__mocks__/react-native-key-command.ts @@ -0,0 +1,9 @@ +import type {addListener as _addListener, constants as _constants} from 'react-native-key-command'; + +const registerKeyCommands = () => {}; +const unregisterKeyCommands = () => {}; +const constants: Partial = {}; +const eventEmitter = () => {}; +const addListener: typeof _addListener = () => () => {}; + +export {addListener, constants, eventEmitter, registerKeyCommands, unregisterKeyCommands}; diff --git a/android/app/build.gradle b/android/app/build.gradle index ed7b2f568b93..ae50affa1115 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 1001043907 - versionName "1.4.39-7" + versionCode 1001044003 + versionName "1.4.40-3" } flavorDimensions "default" diff --git a/android/app/src/main/res/raw/attention.mp3 b/android/app/src/main/res/raw/attention.mp3 new file mode 100644 index 000000000000..854be9cbcf07 Binary files /dev/null and b/android/app/src/main/res/raw/attention.mp3 differ diff --git a/android/app/src/main/res/raw/done.mp3 b/android/app/src/main/res/raw/done.mp3 new file mode 100644 index 000000000000..23d6a2b6bdb5 Binary files /dev/null and b/android/app/src/main/res/raw/done.mp3 differ diff --git a/android/app/src/main/res/raw/receive.mp3 b/android/app/src/main/res/raw/receive.mp3 new file mode 100644 index 000000000000..28f03052a14b Binary files /dev/null and b/android/app/src/main/res/raw/receive.mp3 differ diff --git a/android/app/src/main/res/raw/success.mp3 b/android/app/src/main/res/raw/success.mp3 new file mode 100644 index 000000000000..bd1af6526e40 Binary files /dev/null and b/android/app/src/main/res/raw/success.mp3 differ diff --git a/assets/css/fonts.css b/assets/css/fonts.css index 078cec114c31..e93fb6d806da 100644 --- a/assets/css/fonts.css +++ b/assets/css/fonts.css @@ -55,8 +55,8 @@ } @font-face { - font-family: Windows Segoe UI Emoji; - src: url('/fonts/seguiemj.ttf'); + font-family: Noto Color Emoji; + src: url('/fonts/NotoColorEmoji-Regular.woff2') format('woff2'), url('/fonts/NotoColorEmoji-Regular.woff') format('woff'); } * { diff --git a/assets/fonts/web/NotoColorEmoji-Regular.woff b/assets/fonts/web/NotoColorEmoji-Regular.woff new file mode 100644 index 000000000000..ba2eccd5eac9 Binary files /dev/null and b/assets/fonts/web/NotoColorEmoji-Regular.woff differ diff --git a/assets/fonts/web/NotoColorEmoji-Regular.woff2 b/assets/fonts/web/NotoColorEmoji-Regular.woff2 new file mode 100644 index 000000000000..7cb695dc176b Binary files /dev/null and b/assets/fonts/web/NotoColorEmoji-Regular.woff2 differ diff --git a/assets/sounds/attention.mp3 b/assets/sounds/attention.mp3 new file mode 100644 index 000000000000..854be9cbcf07 Binary files /dev/null and b/assets/sounds/attention.mp3 differ diff --git a/assets/sounds/done.mp3 b/assets/sounds/done.mp3 new file mode 100644 index 000000000000..23d6a2b6bdb5 Binary files /dev/null and b/assets/sounds/done.mp3 differ diff --git a/assets/sounds/receive.mp3 b/assets/sounds/receive.mp3 new file mode 100644 index 000000000000..28f03052a14b Binary files /dev/null and b/assets/sounds/receive.mp3 differ diff --git a/assets/sounds/success.mp3 b/assets/sounds/success.mp3 new file mode 100644 index 000000000000..bd1af6526e40 Binary files /dev/null and b/assets/sounds/success.mp3 differ diff --git a/config/webpack/webpack.common.js b/config/webpack/webpack.common.js index 9b1a28382fa2..8ad574d3b2e0 100644 --- a/config/webpack/webpack.common.js +++ b/config/webpack/webpack.common.js @@ -58,7 +58,9 @@ const webpackConfig = ({envFile = '.env', platform = 'web'}) => ({ publicPath: '/', }, stats: { - warningsFilter: [], + // We can ignore the "module not installed" warning from lottie-react-native + // because we are not using the library for JSON format of Lottie animations. + warningsFilter: ['./node_modules/lottie-react-native/lib/module/LottieView/index.web.js'], }, plugins: [ new CleanWebpackPlugin(), @@ -97,6 +99,7 @@ const webpackConfig = ({envFile = '.env', platform = 'web'}) => ({ {from: 'web/manifest.json'}, {from: 'assets/css', to: 'css'}, {from: 'assets/fonts/web', to: 'fonts'}, + {from: 'assets/sounds', to: 'sounds'}, {from: 'node_modules/react-pdf/dist/esm/Page/AnnotationLayer.css', to: 'css/AnnotationLayer.css'}, {from: 'node_modules/react-pdf/dist/esm/Page/TextLayer.css', to: 'css/TextLayer.css'}, {from: 'assets/images/shadow.png', to: 'images/shadow.png'}, @@ -200,7 +203,7 @@ const webpackConfig = ({envFile = '.env', platform = 'web'}) => ({ alias: { 'react-native-config': 'react-web-config', 'react-native$': 'react-native-web', - + 'react-native-sound': 'react-native-web-sound', // Module alias for web & desktop // https://webpack.js.org/configuration/resolve/#resolvealias '@assets': path.resolve(__dirname, '../../assets'), @@ -239,6 +242,7 @@ const webpackConfig = ({envFile = '.env', platform = 'web'}) => ({ 'process/browser': require.resolve('process/browser'), }, }, + optimization: { runtimeChunk: 'single', splitChunks: { diff --git a/desktop/package-lock.json b/desktop/package-lock.json index 268c706bedef..bdc1b8e4bb1e 100644 --- a/desktop/package-lock.json +++ b/desktop/package-lock.json @@ -9,7 +9,7 @@ "dependencies": { "electron-context-menu": "^2.3.0", "electron-log": "^4.4.8", - "electron-serve": "^1.2.0", + "electron-serve": "^1.3.0", "electron-updater": "^6.1.7", "node-machine-id": "^1.1.12" } @@ -145,9 +145,9 @@ "integrity": "sha512-QQ4GvrXO+HkgqqEOYbi+DHL7hj5JM+nHi/j+qrN9zeeXVKy8ZABgbu4CnG+BBqDZ2+tbeq9tUC4DZfIWFU5AZA==" }, "node_modules/electron-serve": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/electron-serve/-/electron-serve-1.2.0.tgz", - "integrity": "sha512-zJG3wisMrDn2G/gnjrhyB074COvly1FnS0U7Edm8bfXLB8MYX7UtwR9/y2LkFreYjzQHm9nEbAfgCmF+9M9LHQ==", + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/electron-serve/-/electron-serve-1.3.0.tgz", + "integrity": "sha512-OEC/48ZBJxR6XNSZtCl4cKPyQ1lvsu8yp8GdCplMWwGS1eEyMcEmzML5BRs/io/RLDnpgyf+7rSL+X6ICifRIg==", "engines": { "node": ">=12" }, @@ -536,9 +536,9 @@ "integrity": "sha512-QQ4GvrXO+HkgqqEOYbi+DHL7hj5JM+nHi/j+qrN9zeeXVKy8ZABgbu4CnG+BBqDZ2+tbeq9tUC4DZfIWFU5AZA==" }, "electron-serve": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/electron-serve/-/electron-serve-1.2.0.tgz", - "integrity": "sha512-zJG3wisMrDn2G/gnjrhyB074COvly1FnS0U7Edm8bfXLB8MYX7UtwR9/y2LkFreYjzQHm9nEbAfgCmF+9M9LHQ==" + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/electron-serve/-/electron-serve-1.3.0.tgz", + "integrity": "sha512-OEC/48ZBJxR6XNSZtCl4cKPyQ1lvsu8yp8GdCplMWwGS1eEyMcEmzML5BRs/io/RLDnpgyf+7rSL+X6ICifRIg==" }, "electron-updater": { "version": "6.1.7", diff --git a/desktop/package.json b/desktop/package.json index 563a45851eb2..da1610e49b23 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -6,7 +6,7 @@ "dependencies": { "electron-context-menu": "^2.3.0", "electron-log": "^4.4.8", - "electron-serve": "^1.2.0", + "electron-serve": "^1.3.0", "electron-updater": "^6.1.7", "node-machine-id": "^1.1.12" }, diff --git a/docs/articles/expensify-classic/getting-started/playbooks/Expensify-Playbook-For-Small-To-Medium-Sized-Businesses.md b/docs/articles/expensify-classic/getting-started/playbooks/Expensify-Playbook-For-Small-To-Medium-Sized-Businesses.md index f7605fd93b2e..9e35e51ec973 100644 --- a/docs/articles/expensify-classic/getting-started/playbooks/Expensify-Playbook-For-Small-To-Medium-Sized-Businesses.md +++ b/docs/articles/expensify-classic/getting-started/playbooks/Expensify-Playbook-For-Small-To-Medium-Sized-Businesses.md @@ -38,7 +38,7 @@ To create your Control Workspace: 2. Select *Group* and click the button that says *New Workspace* 3. Click *Select* under Control -The Control Plan also gives you access to a dedicated Setup Specialist. You can find yours by looking at your workspace's *#admins* room in *[new.expensify.com](https://new.expensify.com)*, and in your company’s workspace settings in the *Overview* tab, where you can chat with them and schedule an onboarding call to walk through any setup questions. The Control Plan bundled with the Expensify Card is only *$9 per user per month* (not taking into account cash back your earn) when you commit annually. That’s a 75% discount off the unbundled price point if you choose to use a different Corporate Card (or no) provider. +The Control Plan also gives you access to a dedicated Setup Specialist. You can find yours by looking at your workspace's *#admins* room in *[new.expensify.com](https://new.expensify.com)*, and in your company’s workspace settings in the *Overview* tab, where you can chat with them and schedule an onboarding call to walk through any setup questions. The Control Plan bundled with the Expensify Visa® Commercial Card is only *$9 per user per month* (not taking into account cash back your earn) when you commit annually. Adopting the Expensify Card with an Annual Subscription gives a 75% discount off the unbundled price. ## Step 3: Connect your accounting system As a small to medium-sized business, it's important to maintain proper spend management to ensure the success and stability of your organization. This requires paying close attention to your expenses, streamlining your financial processes, and making sure that your financial information is accurate, compliant, and transparent. Include best practices such as: @@ -143,7 +143,7 @@ Let’s walk through the process of linking your business bank account: 4. Once that’s done, we’ll collect all of the necessary information on your business, such as your legal business name and address 5. We’ll then collect your personal information, and a photo ID to confirm your identity -You only need to do this once: you are fully set up for not only reimbursing expense reports, but issuing Expensify Cards, collecting customer invoice payments online (if applicable), as well as paying supplier bills online. +You only need to do this once: you are fully set up for not only reimbursing expense reports, but granting Expensify Cards, collecting customer invoice payments online (if applicable), as well as paying supplier bills online. ## Step 9: Invite employees and set an approval workflow *Select an Approval Mode* @@ -194,8 +194,8 @@ As mentioned above, we’ll be able to pull in transactions as they post (daily) ### If you don't have a corporate card, use the Expensify Card (US only) Expensify provides a corporate card with the following features: -- Up to 2% cash back -- [SmartLimits](https://help.expensify.com/articles/expensify-classic/expensify-card/Card-Settings) to control what each individual cardholder can spend +- Up to 2% cash back (_Applies to USD purchases only._) +- [SmartLimits]([https://help.expensify.com/articles/expensify-classic/expensify-card/Card-Settings](https://community.expensify.com/discussion/4851/deep-dive-what-are-smart-limits?utm_source=community-search&utm_medium=organic-search&utm_term=smart+limits)) to control what each individual cardholder can spend - A stable, unbreakable real-time connection (third-party bank feeds can run into connectivity issues) - Receipt compliance - informing notifications (e.g. add a receipt!) for users *as soon as the card is swiped* - Unlimited Virtual Cards - single-purpose cards with a fixed or monthly limit for specific company purchases @@ -225,7 +225,7 @@ As a small business, managing bills and invoices can be a complex and time-consu Here are some of the key benefits of using Expensify for bill payments and invoicing: - Flexible payment options: Expensify allows you to pay your bills via ACH, credit card, or check, so you can choose the option that works best for you (US businesses only). -- Free, No Fees: The bill pay and invoicing features come included with every workspace and workspace, so you won't need to pay any additional fees. +- No Cost Feature: The bill pay and invoicing features come included with every workspace and plan. - Integration with your business bank account: With your business bank account verified, you can easily link your finances to receive payment from customers when invoices are paid. Let’s first chat through how Bill Pay works diff --git a/ios/NewExpensify.xcodeproj/project.pbxproj b/ios/NewExpensify.xcodeproj/project.pbxproj index 5d3bf1c07985..9b1451b2bf94 100644 --- a/ios/NewExpensify.xcodeproj/project.pbxproj +++ b/ios/NewExpensify.xcodeproj/project.pbxproj @@ -8,6 +8,10 @@ /* Begin PBXBuildFile section */ 059DC4EFD39EF39437E6823D /* libPods-NotificationServiceExtension.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 1A997AA8204EA3D90907FA80 /* libPods-NotificationServiceExtension.a */; }; + 083353EB2B5AB22A00C603C0 /* attention.mp3 in Resources */ = {isa = PBXBuildFile; fileRef = 083353E72B5AB22900C603C0 /* attention.mp3 */; }; + 083353EC2B5AB22A00C603C0 /* done.mp3 in Resources */ = {isa = PBXBuildFile; fileRef = 083353E82B5AB22900C603C0 /* done.mp3 */; }; + 083353ED2B5AB22A00C603C0 /* receive.mp3 in Resources */ = {isa = PBXBuildFile; fileRef = 083353E92B5AB22900C603C0 /* receive.mp3 */; }; + 083353EE2B5AB22A00C603C0 /* success.mp3 in Resources */ = {isa = PBXBuildFile; fileRef = 083353EA2B5AB22900C603C0 /* success.mp3 */; }; 0C7C65547D7346EB923BE808 /* ExpensifyMono-Regular.otf in Resources */ = {isa = PBXBuildFile; fileRef = E704648954784DDFBAADF568 /* ExpensifyMono-Regular.otf */; }; 0CDA8E34287DD650004ECBEC /* AppDelegate.mm in Sources */ = {isa = PBXBuildFile; fileRef = 0CDA8E33287DD650004ECBEC /* AppDelegate.mm */; }; 0CDA8E35287DD650004ECBEC /* AppDelegate.mm in Sources */ = {isa = PBXBuildFile; fileRef = 0CDA8E33287DD650004ECBEC /* AppDelegate.mm */; }; @@ -79,6 +83,10 @@ 00E356EE1AD99517003FC87E /* NewExpensifyTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = NewExpensifyTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 00E356F11AD99517003FC87E /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 076FD9E41E08971BBF51D580 /* libPods-NewExpensify-NewExpensifyTests.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-NewExpensify-NewExpensifyTests.a"; sourceTree = BUILT_PRODUCTS_DIR; }; + 083353E72B5AB22900C603C0 /* attention.mp3 */ = {isa = PBXFileReference; lastKnownFileType = audio.mp3; name = attention.mp3; path = ../assets/sounds/attention.mp3; sourceTree = ""; }; + 083353E82B5AB22900C603C0 /* done.mp3 */ = {isa = PBXFileReference; lastKnownFileType = audio.mp3; name = done.mp3; path = ../assets/sounds/done.mp3; sourceTree = ""; }; + 083353E92B5AB22900C603C0 /* receive.mp3 */ = {isa = PBXFileReference; lastKnownFileType = audio.mp3; name = receive.mp3; path = ../assets/sounds/receive.mp3; sourceTree = ""; }; + 083353EA2B5AB22900C603C0 /* success.mp3 */ = {isa = PBXFileReference; lastKnownFileType = audio.mp3; name = success.mp3; path = ../assets/sounds/success.mp3; sourceTree = ""; }; 0CDA8E33287DD650004ECBEC /* AppDelegate.mm */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.cpp.objcpp; name = AppDelegate.mm; path = NewExpensify/AppDelegate.mm; sourceTree = ""; }; 0CDA8E36287DD6A0004ECBEC /* Images.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Images.xcassets; path = NewExpensify/Images.xcassets; sourceTree = ""; }; 0F5BE0CD252686320097D869 /* GoogleService-Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = "GoogleService-Info.plist"; sourceTree = ""; }; @@ -311,6 +319,10 @@ A9EA265D209D4558995C9BD4 /* Resources */ = { isa = PBXGroup; children = ( + 083353E72B5AB22900C603C0 /* attention.mp3 */, + 083353E82B5AB22900C603C0 /* done.mp3 */, + 083353E92B5AB22900C603C0 /* receive.mp3 */, + 083353EA2B5AB22900C603C0 /* success.mp3 */, 44BF435285B94E5B95F90994 /* ExpensifyNewKansas-Medium.otf */, D2AFB39EC1D44BF9B91D3227 /* ExpensifyNewKansas-MediumItalic.otf */, DCF33E34FFEC48128CDD41D4 /* ExpensifyMono-Bold.otf */, @@ -496,13 +508,17 @@ 0F5BE0CE252686330097D869 /* GoogleService-Info.plist in Resources */, E9DF872D2525201700607FDC /* AirshipConfig.plist in Resources */, F0C450EA2705020500FD2970 /* colors.json in Resources */, + 083353EB2B5AB22A00C603C0 /* attention.mp3 in Resources */, 0CDA8E37287DD6A0004ECBEC /* Images.xcassets in Resources */, 70CF6E82262E297300711ADC /* BootSplash.storyboard in Resources */, FF941A8D48F849269AB85C9A /* ExpensifyNewKansas-Medium.otf in Resources */, BDB853621F354EBB84E619C2 /* ExpensifyNewKansas-MediumItalic.otf in Resources */, 26AF3C3540374A9FACB6C19E /* ExpensifyMono-Bold.otf in Resources */, + 083353EE2B5AB22A00C603C0 /* success.mp3 in Resources */, 0C7C65547D7346EB923BE808 /* ExpensifyMono-Regular.otf in Resources */, 2A9F8CDA983746B0B9204209 /* ExpensifyNeue-Bold.otf in Resources */, + 083353EC2B5AB22A00C603C0 /* done.mp3 in Resources */, + 083353ED2B5AB22A00C603C0 /* receive.mp3 in Resources */, ED222ED90E074A5481A854FA /* ExpensifyNeue-BoldItalic.otf in Resources */, 30581EA8AAFD4FCE88C5D191 /* ExpensifyNeue-Italic.otf in Resources */, 1246A3EF20E54E7A9494C8B9 /* ExpensifyNeue-Regular.otf in Resources */, diff --git a/ios/NewExpensify/Info.plist b/ios/NewExpensify/Info.plist index 4f571739ecc7..096c013bfc74 100644 --- a/ios/NewExpensify/Info.plist +++ b/ios/NewExpensify/Info.plist @@ -19,7 +19,7 @@ CFBundlePackageType APPL CFBundleShortVersionString - 1.4.39 + 1.4.40 CFBundleSignature ???? CFBundleURLTypes @@ -40,7 +40,7 @@ CFBundleVersion - 1.4.39.7 + 1.4.40.3 ITSAppUsesNonExemptEncryption LSApplicationQueriesSchemes diff --git a/ios/NewExpensifyTests/Info.plist b/ios/NewExpensifyTests/Info.plist index bc29fdff60c2..4de4b9aca695 100644 --- a/ios/NewExpensifyTests/Info.plist +++ b/ios/NewExpensifyTests/Info.plist @@ -15,10 +15,10 @@ CFBundlePackageType BNDL CFBundleShortVersionString - 1.4.39 + 1.4.40 CFBundleSignature ???? CFBundleVersion - 1.4.39.7 + 1.4.40.3 diff --git a/ios/NotificationServiceExtension/Info.plist b/ios/NotificationServiceExtension/Info.plist index b54ddc36ddf0..b3b8b5c2298b 100644 --- a/ios/NotificationServiceExtension/Info.plist +++ b/ios/NotificationServiceExtension/Info.plist @@ -11,9 +11,9 @@ CFBundleName $(PRODUCT_NAME) CFBundleShortVersionString - 1.4.39 + 1.4.40 CFBundleVersion - 1.4.39.7 + 1.4.40.3 NSExtension NSExtensionPointIdentifier diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 2c05bf4175d8..19a579b846e8 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -1166,7 +1166,7 @@ PODS: - react-native-config/App (= 1.4.6) - react-native-config/App (1.4.6): - React-Core - - react-native-document-picker (8.2.1): + - react-native-document-picker (9.1.1): - React-Core - react-native-geolocation (3.0.6): - React-Core @@ -1372,7 +1372,7 @@ PODS: - React-Core - RNCAsyncStorage (1.21.0): - React-Core - - RNCClipboard (1.12.1): + - RNCClipboard (1.13.2): - React-Core - RNCPicker (2.5.1): - React-Core @@ -1438,6 +1438,11 @@ PODS: - glog - RCT-Folly (= 2022.05.16.00) - React-Core + - RNSound (0.11.2): + - React-Core + - RNSound/Core (= 0.11.2) + - RNSound/Core (0.11.2): + - React-Core - RNSVG (14.0.0): - React-Core - SDWebImage (5.17.0): @@ -1580,6 +1585,7 @@ DEPENDENCIES: - RNReactNativeHapticFeedback (from `../node_modules/react-native-haptic-feedback`) - RNReanimated (from `../node_modules/react-native-reanimated`) - RNScreens (from `../node_modules/react-native-screens`) + - RNSound (from `../node_modules/react-native-sound`) - RNSVG (from `../node_modules/react-native-svg`) - VisionCamera (from `../node_modules/react-native-vision-camera`) - Yoga (from `../node_modules/react-native/ReactCommon/yoga`) @@ -1827,6 +1833,8 @@ EXTERNAL SOURCES: :path: "../node_modules/react-native-reanimated" RNScreens: :path: "../node_modules/react-native-screens" + RNSound: + :path: "../node_modules/react-native-sound" RNSVG: :path: "../node_modules/react-native-svg" VisionCamera: @@ -1916,7 +1924,7 @@ SPEC CHECKSUMS: react-native-blob-util: 30a6c9fd067aadf9177e61a998f2c7efb670598d react-native-cameraroll: 8ffb0af7a5e5de225fd667610e2979fc1f0c2151 react-native-config: 7cd105e71d903104e8919261480858940a6b9c0e - react-native-document-picker: 69ca2094d8780cfc1e7e613894d15290fdc54bba + react-native-document-picker: 3599b238843369026201d2ef466df53f77ae0452 react-native-geolocation: 0f7fe8a4c2de477e278b0365cce27d089a8c5903 react-native-image-manipulator: c48f64221cfcd46e9eec53619c4c0374f3328a56 react-native-image-picker: c33d4e79f0a14a2b66e5065e14946ae63749660b @@ -1954,7 +1962,7 @@ SPEC CHECKSUMS: ReactCommon: 45b5d4f784e869c44a6f5a8fad5b114ca8f78c53 RNAppleAuthentication: 0571c08da8c327ae2afc0261b48b4a515b0286a6 RNCAsyncStorage: 618d03a5f52fbccb3d7010076bc54712844c18ef - RNCClipboard: d77213bfa269013bf4b857b7a9ca37ee062d8ef1 + RNCClipboard: 60fed4b71560d7bfe40e9d35dea9762b024da86d RNCPicker: 529d564911e93598cc399b56cc0769ce3675f8c8 RNDeviceInfo: 4701f0bf2a06b34654745053db0ce4cb0c53ada7 RNDevMenu: 72807568fe4188bd4c40ce32675d82434b43c45d @@ -1973,6 +1981,7 @@ SPEC CHECKSUMS: RNReactNativeHapticFeedback: 1e3efeca9628ff9876ee7cdd9edec1b336913f8c RNReanimated: 57f436e7aa3d277fbfed05e003230b43428157c0 RNScreens: b582cb834dc4133307562e930e8fa914b8c04ef2 + RNSound: 6c156f925295bdc83e8e422e7d8b38d33bc71852 RNSVG: 255767813dac22db1ec2062c8b7e7b856d4e5ae6 SDWebImage: 750adf017a315a280c60fde706ab1e552a3ae4e9 SDWebImageAVIFCoder: 8348fef6d0ec69e129c66c9fe4d74fbfbf366112 diff --git a/jest.config.js b/jest.config.js index b347db593d83..95ecc350ed9f 100644 --- a/jest.config.js +++ b/jest.config.js @@ -26,6 +26,6 @@ module.exports = { setupFilesAfterEnv: ['@testing-library/jest-native/extend-expect', '/jest/setupAfterEnv.ts', '/tests/perf-test/setupAfterEnv.js'], cacheDirectory: '/.jest-cache', moduleNameMapper: { - '\\.(lottie)$': '/__mocks__/fileMock.js', + '\\.(lottie)$': '/__mocks__/fileMock.ts', }, }; diff --git a/jest/setup.ts b/jest/setup.ts index 68d904fac5be..55774ff136f1 100644 --- a/jest/setup.ts +++ b/jest/setup.ts @@ -38,3 +38,11 @@ jest.mock('react-native-fs', () => ({ unlink: jest.fn(() => new Promise((res) => res())), CachesDirectoryPath: jest.fn(), })); + +jest.mock('react-native-sound', () => { + class SoundMock { + play = jest.fn(); + } + + return SoundMock; +}); diff --git a/package-lock.json b/package-lock.json index 1cc630ce5dac..18eab487e9fc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "new.expensify", - "version": "1.4.39-7", + "version": "1.4.40-3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "new.expensify", - "version": "1.4.39-7", + "version": "1.4.40-3", "hasInstallScript": true, "license": "MIT", "dependencies": { @@ -27,7 +27,7 @@ "@onfido/react-native-sdk": "8.3.0", "@react-native-async-storage/async-storage": "1.21.0", "@react-native-camera-roll/camera-roll": "5.4.0", - "@react-native-clipboard/clipboard": "^1.12.1", + "@react-native-clipboard/clipboard": "^1.13.2", "@react-native-community/geolocation": "^3.0.6", "@react-native-community/netinfo": "11.2.1", "@react-native-firebase/analytics": "^12.3.0", @@ -52,7 +52,7 @@ "date-fns-tz": "^2.0.0", "dom-serializer": "^0.2.2", "domhandler": "^4.3.0", - "expensify-common": "git+ssh://git@github.com/Expensify/expensify-common.git#4a61536649cbfe49236a35bc7542b5dfd0767e4a", + "expensify-common": "git+ssh://git@github.com/Expensify/expensify-common.git#7aa1d42600d2f59565c1ff962f691b494ccc2813", "expo": "^50.0.3", "expo-image": "1.10.1", "fbjs": "^3.0.2", @@ -82,7 +82,7 @@ "react-native-config": "^1.4.5", "react-native-dev-menu": "^4.1.1", "react-native-device-info": "^10.3.0", - "react-native-document-picker": "^8.2.1", + "react-native-document-picker": "^9.1.1", "react-native-draggable-flatlist": "^4.0.1", "react-native-fs": "^2.20.0", "react-native-gesture-handler": "2.14.1", @@ -101,7 +101,7 @@ "react-native-pdf": "6.7.3", "react-native-performance": "^5.1.0", "react-native-permissions": "^3.9.3", - "react-native-picker-select": "git+https://github.com/Expensify/react-native-picker-select.git#42b334d0c4e71d225601f72828d3dedd0bc22212", + "react-native-picker-select": "git+https://github.com/Expensify/react-native-picker-select.git#da50d2c5c54e268499047f9cc98b8df4196c1ddf", "react-native-plaid-link-sdk": "10.8.0", "react-native-qrcode-svg": "^6.2.0", "react-native-quick-sqlite": "^8.0.0-beta.2", @@ -109,13 +109,15 @@ "react-native-render-html": "6.3.1", "react-native-safe-area-context": "4.7.4", "react-native-screens": "3.29.0", - "react-native-svg": "14.0.0", + "react-native-sound": "^0.11.2", + "react-native-svg": "14.1.0", "react-native-tab-view": "^3.5.2", "react-native-url-polyfill": "^2.0.0", "react-native-view-shot": "3.8.0", "react-native-vision-camera": "2.16.8", "react-native-web": "^0.19.9", "react-native-web-linear-gradient": "^1.1.2", + "react-native-web-sound": "^0.1.3", "react-native-webview": "13.6.3", "react-pdf": "7.3.3", "react-plaid-link": "3.3.2", @@ -8736,9 +8738,9 @@ } }, "node_modules/@react-native-clipboard/clipboard": { - "version": "1.12.1", - "resolved": "https://registry.npmjs.org/@react-native-clipboard/clipboard/-/clipboard-1.12.1.tgz", - "integrity": "sha512-+PNk8kflpGte0W1Nz61/Dp8gHTxyuRjkVyRYBawymSIGTDHCC/zOJSbig6kGIkD8MeaGHC2vGYQJyUyCrgVPBQ==", + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@react-native-clipboard/clipboard/-/clipboard-1.13.2.tgz", + "integrity": "sha512-uVM55oEGc6a6ZmSATDeTcMm55A/C1km5X47g0xaoF0Zagv7N/8RGvLceA5L/izPwflIy78t7XQeJUcnGSib0nA==", "peerDependencies": { "react": ">=16.0", "react-native": ">=0.57.0" @@ -31234,8 +31236,8 @@ }, "node_modules/expensify-common": { "version": "1.0.0", - "resolved": "git+ssh://git@github.com/Expensify/expensify-common.git#4a61536649cbfe49236a35bc7542b5dfd0767e4a", - "integrity": "sha512-UOy3btYvKRZ1kS4etLPw6Lgfqx+yiM3GMd340K06YLasn24alKgMOmg2dqSRTApF7RltS2FjOXRddAhzgvJZ3w==", + "resolved": "git+ssh://git@github.com/Expensify/expensify-common.git#7aa1d42600d2f59565c1ff962f691b494ccc2813", + "integrity": "sha512-f5zeUthFwmWFiGO1as+ARuYv7TbXVktnKAjoFKhaVVSTZvgbojSnzGClCCh4uwbdl9HDRv7aiXfRN1nzuIFOTg==", "license": "MIT", "dependencies": { "classnames": "2.4.0", @@ -33754,6 +33756,11 @@ "node": ">=10" } }, + "node_modules/howler": { + "version": "2.2.4", + "resolved": "https://registry.npmjs.org/howler/-/howler-2.2.4.tgz", + "integrity": "sha512-iARIBPgcQrwtEr+tALF+rapJ8qSc+Set2GJQl7xT1MQzWaVkFebdJhR3alVlSiUf5U7nAANKuj3aWpwerocD5w==" + }, "node_modules/hpack.js": { "version": "2.1.6", "resolved": "https://registry.npmjs.org/hpack.js/-/hpack.js-2.1.6.tgz", @@ -44903,9 +44910,9 @@ } }, "node_modules/react-native-document-picker": { - "version": "8.2.1", - "resolved": "https://registry.npmjs.org/react-native-document-picker/-/react-native-document-picker-8.2.1.tgz", - "integrity": "sha512-luH2hKdq4cUwE651OscyGderLMsCusOsBzw4MBca91CgprlAGVMm1/pDwJDV5t9LIewVK8DIgXGXzgrsusKVhA==", + "version": "9.1.1", + "resolved": "https://registry.npmjs.org/react-native-document-picker/-/react-native-document-picker-9.1.1.tgz", + "integrity": "sha512-BW+7DbsILuFThlBm7NUFVUmKKf6Awkcf9R0q8wiCU2DlGGtAKQTt2iHpO5+Dn/7WMPB+rqNv3X1HsmJQ0t5R3g==", "dependencies": { "invariant": "^2.2.4" }, @@ -45202,8 +45209,8 @@ }, "node_modules/react-native-picker-select": { "version": "8.1.0", - "resolved": "git+ssh://git@github.com/Expensify/react-native-picker-select.git#42b334d0c4e71d225601f72828d3dedd0bc22212", - "integrity": "sha512-e8TAWVR4AEw2PFGFxlevCBFr1RwvwTqq1M2w9Yi6xNz+d4SbG6tDIcJDNIqt0gyBqvxlL7BuK0G5BjbiZDLKsg==", + "resolved": "git+ssh://git@github.com/Expensify/react-native-picker-select.git#da50d2c5c54e268499047f9cc98b8df4196c1ddf", + "integrity": "sha512-iASqj8cXSQY+P3ZhfW1eoVcK0UB+TRTddrNSQ3lmIH0a4lYO3m4XJC+cnoCjjPl/sTzUaShpOnpBfqMueR6UMA==", "license": "MIT", "dependencies": { "lodash.isequal": "^4.5.0" @@ -45343,10 +45350,18 @@ "react-native": "*" } }, + "node_modules/react-native-sound": { + "version": "0.11.2", + "resolved": "https://registry.npmjs.org/react-native-sound/-/react-native-sound-0.11.2.tgz", + "integrity": "sha512-LmGc8lgOK3qecYMVQpyHvww/C+wgT6sWeMpVbOe4NCRGC2yKd4fo4U0KBUo9PO7AqKESO3I/2GZg1/C0+bwiiA==", + "peerDependencies": { + "react-native": ">=0.8.0" + } + }, "node_modules/react-native-svg": { - "version": "14.0.0", - "resolved": "https://registry.npmjs.org/react-native-svg/-/react-native-svg-14.0.0.tgz", - "integrity": "sha512-17W/gWXRUMS7p7PSHu/WyGkZUc1NzRTGxxXc0VqBLjzKSchyb0EmgsiWf9aKmfC6gmY0wcsmKZcGV41bCcNfBA==", + "version": "14.1.0", + "resolved": "https://registry.npmjs.org/react-native-svg/-/react-native-svg-14.1.0.tgz", + "integrity": "sha512-HeseElmEk+AXGwFZl3h56s0LtYD9HyGdrpg8yd9QM26X+d7kjETrRQ9vCjtxuT5dCZEIQ5uggU1dQhzasnsCWA==", "dependencies": { "css-select": "^5.1.0", "css-tree": "^1.1.3" @@ -45428,6 +45443,17 @@ "react-native-web": "*" } }, + "node_modules/react-native-web-sound": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/react-native-web-sound/-/react-native-web-sound-0.1.3.tgz", + "integrity": "sha512-aUWNZuRFM7aREePiDgsTaBaaKX+aHKF0cDXtfOwFn4fQXkN+w5Ny3HykFrbDMxZagXSav5QjPntcA51lpWnSgg==", + "dependencies": { + "howler": "^2.2.1" + }, + "peerDependencies": { + "react-native-web": "*" + } + }, "node_modules/react-native-web/node_modules/memoize-one": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-6.0.0.tgz", diff --git a/package.json b/package.json index 5870241897b9..8dee0e835b40 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "new.expensify", - "version": "1.4.39-7", + "version": "1.4.40-3", "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.", @@ -75,7 +75,7 @@ "@onfido/react-native-sdk": "8.3.0", "@react-native-async-storage/async-storage": "1.21.0", "@react-native-camera-roll/camera-roll": "5.4.0", - "@react-native-clipboard/clipboard": "^1.12.1", + "@react-native-clipboard/clipboard": "^1.13.2", "@react-native-community/geolocation": "^3.0.6", "@react-native-community/netinfo": "11.2.1", "@react-native-firebase/analytics": "^12.3.0", @@ -100,7 +100,7 @@ "date-fns-tz": "^2.0.0", "dom-serializer": "^0.2.2", "domhandler": "^4.3.0", - "expensify-common": "git+ssh://git@github.com/Expensify/expensify-common.git#4a61536649cbfe49236a35bc7542b5dfd0767e4a", + "expensify-common": "git+ssh://git@github.com/Expensify/expensify-common.git#7aa1d42600d2f59565c1ff962f691b494ccc2813", "expo": "^50.0.3", "expo-image": "1.10.1", "fbjs": "^3.0.2", @@ -130,7 +130,7 @@ "react-native-config": "^1.4.5", "react-native-dev-menu": "^4.1.1", "react-native-device-info": "^10.3.0", - "react-native-document-picker": "^8.2.1", + "react-native-document-picker": "^9.1.1", "react-native-draggable-flatlist": "^4.0.1", "react-native-fs": "^2.20.0", "react-native-gesture-handler": "2.14.1", @@ -149,7 +149,7 @@ "react-native-pdf": "6.7.3", "react-native-performance": "^5.1.0", "react-native-permissions": "^3.9.3", - "react-native-picker-select": "git+https://github.com/Expensify/react-native-picker-select.git#42b334d0c4e71d225601f72828d3dedd0bc22212", + "react-native-picker-select": "git+https://github.com/Expensify/react-native-picker-select.git#da50d2c5c54e268499047f9cc98b8df4196c1ddf", "react-native-plaid-link-sdk": "10.8.0", "react-native-qrcode-svg": "^6.2.0", "react-native-quick-sqlite": "^8.0.0-beta.2", @@ -157,13 +157,15 @@ "react-native-render-html": "6.3.1", "react-native-safe-area-context": "4.7.4", "react-native-screens": "3.29.0", - "react-native-svg": "14.0.0", + "react-native-sound": "^0.11.2", + "react-native-svg": "14.1.0", "react-native-tab-view": "^3.5.2", "react-native-url-polyfill": "^2.0.0", "react-native-view-shot": "3.8.0", "react-native-vision-camera": "2.16.8", "react-native-web": "^0.19.9", "react-native-web-linear-gradient": "^1.1.2", + "react-native-web-sound": "^0.1.3", "react-native-webview": "13.6.3", "react-pdf": "7.3.3", "react-plaid-link": "3.3.2", diff --git a/patches/react-native-sound+0.11.2.patch b/patches/react-native-sound+0.11.2.patch new file mode 100644 index 000000000000..661e39263c43 --- /dev/null +++ b/patches/react-native-sound+0.11.2.patch @@ -0,0 +1,38 @@ +diff --git a/node_modules/react-native-sound/RNSound/RNSound.h b/node_modules/react-native-sound/RNSound/RNSound.h +index 7f5b97b..1a3c840 100644 +--- a/node_modules/react-native-sound/RNSound/RNSound.h ++++ b/node_modules/react-native-sound/RNSound/RNSound.h +@@ -1,17 +1,7 @@ +-#if __has_include() + #import +-#else +-#import "RCTBridgeModule.h" +-#endif +- + #import +- +-#if __has_include() + #import +-#else +-#import "RCTEventEmitter.h" +-#endif + + @interface RNSound : RCTEventEmitter +-@property (nonatomic, weak) NSNumber *_key; ++@property(nonatomic, weak) NSNumber *_key; + @end +diff --git a/node_modules/react-native-sound/RNSound/RNSound.m b/node_modules/react-native-sound/RNSound/RNSound.m +index df3784e..d34ac01 100644 +--- a/node_modules/react-native-sound/RNSound/RNSound.m ++++ b/node_modules/react-native-sound/RNSound/RNSound.m +@@ -1,10 +1,6 @@ + #import "RNSound.h" + +-#if __has_include("RCTUtils.h") +-#import "RCTUtils.h" +-#else + #import +-#endif + + @implementation RNSound { + NSMutableDictionary *_playerPool; diff --git a/patches/react-native-web+0.19.9+003+fix-pointer-events.patch b/patches/react-native-web+0.19.9+003+fix-pointer-events.patch deleted file mode 100644 index a457fbcfe36c..000000000000 --- a/patches/react-native-web+0.19.9+003+fix-pointer-events.patch +++ /dev/null @@ -1,22 +0,0 @@ -diff --git a/node_modules/react-native-web/dist/exports/StyleSheet/compiler/index.js b/node_modules/react-native-web/dist/exports/StyleSheet/compiler/index.js -index bdcecc2..63f1364 100644 ---- a/node_modules/react-native-web/dist/exports/StyleSheet/compiler/index.js -+++ b/node_modules/react-native-web/dist/exports/StyleSheet/compiler/index.js -@@ -353,7 +353,7 @@ function createAtomicRules(identifier, property, value) { - var _block2 = createDeclarationBlock({ - pointerEvents: 'none' - }); -- rules.push(selector + ">*" + _block2); -+ rules.push(selector + " *" + _block2); - } - } else if (value === 'none' || value === 'box-none') { - finalValue = 'none!important'; -@@ -361,7 +361,7 @@ function createAtomicRules(identifier, property, value) { - var _block3 = createDeclarationBlock({ - pointerEvents: 'auto' - }); -- rules.push(selector + ">*" + _block3); -+ rules.push(selector + " *" + _block3); - } - } - var _block4 = createDeclarationBlock({ diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts index b46d3db8b60d..4a9c809485e5 100755 --- a/src/ONYXKEYS.ts +++ b/src/ONYXKEYS.ts @@ -63,6 +63,14 @@ const ONYXKEYS = { /** Contains all the info for Tasks */ TASK: 'task', + /** + * Contains all the info for Workspace Rate and Unit while editing. + * + * Note: This is not under the COLLECTION key as we can edit rate and unit + * for one workspace only at a time. And we don't need to store + * rates and units for different workspaces at the same time. */ + WORKSPACE_RATE_AND_UNIT: 'workspaceRateAndUnit', + /** Contains a list of all currencies available to the user - user can * select a currency based on the list */ CURRENCY_LIST: 'currencyList', @@ -393,6 +401,7 @@ type OnyxValues = { [ONYXKEYS.PERSONAL_DETAILS_LIST]: OnyxTypes.PersonalDetailsList; [ONYXKEYS.PRIVATE_PERSONAL_DETAILS]: OnyxTypes.PrivatePersonalDetails; [ONYXKEYS.TASK]: OnyxTypes.Task; + [ONYXKEYS.WORKSPACE_RATE_AND_UNIT]: OnyxTypes.WorkspaceRateAndUnit; [ONYXKEYS.CURRENCY_LIST]: Record; [ONYXKEYS.UPDATE_AVAILABLE]: boolean; [ONYXKEYS.SCREEN_SHARE_REQUEST]: OnyxTypes.ScreenShareRequest; @@ -450,6 +459,7 @@ type OnyxValues = { [ONYXKEYS.MAX_CANVAS_AREA]: number; [ONYXKEYS.MAX_CANVAS_HEIGHT]: number; [ONYXKEYS.MAX_CANVAS_WIDTH]: number; + [ONYXKEYS.IS_SEARCHING_FOR_REPORTS]: boolean; [ONYXKEYS.LAST_VISITED_PATH]: string | undefined; [ONYXKEYS.RECENTLY_USED_REPORT_FIELDS]: OnyxTypes.RecentlyUsedReportFields; [ONYXKEYS.UPDATE_REQUIRED]: boolean; diff --git a/src/ROUTES.ts b/src/ROUTES.ts index 5a2ab8cfc7de..082cea49d771 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -272,10 +272,6 @@ const ROUTES = { route: ':iouType/new/confirmation/:reportID?', getRoute: (iouType: string, reportID = '') => `${iouType}/new/confirmation/${reportID}` as const, }, - MONEY_REQUEST_DATE: { - route: ':iouType/new/date/:reportID?', - getRoute: (iouType: string, reportID = '') => `${iouType}/new/date/${reportID}` as const, - }, MONEY_REQUEST_CURRENCY: { route: ':iouType/new/currency/:reportID?', getRoute: (iouType: string, reportID: string, currency: string, backTo: string) => `${iouType}/new/currency/${reportID}?currency=${currency}&backTo=${backTo}` as const, @@ -337,9 +333,9 @@ const ROUTES = { getUrlWithBackToParam(`create/${iouType}/currency/${transactionID}/${reportID}/${pageIndex}`, backTo), }, MONEY_REQUEST_STEP_DATE: { - route: 'create/:iouType/date/:transactionID/:reportID', - getRoute: (iouType: ValueOf, transactionID: string, reportID: string, backTo = '') => - getUrlWithBackToParam(`create/${iouType}/date/${transactionID}/${reportID}`, backTo), + route: ':action/:iouType/date/:transactionID/:reportID', + getRoute: (action: ValueOf, iouType: ValueOf, transactionID: string, reportID: string, backTo = '') => + getUrlWithBackToParam(`${action}/${iouType}/date/${transactionID}/${reportID}`, backTo), }, MONEY_REQUEST_STEP_DESCRIPTION: { route: ':action/:iouType/description/:transactionID/:reportID', @@ -468,6 +464,14 @@ const ROUTES = { route: 'workspace/:policyID/rateandunit', getRoute: (policyID: string) => `workspace/${policyID}/rateandunit` as const, }, + WORKSPACE_RATE_AND_UNIT_RATE: { + route: 'workspace/:policyID/rateandunit/rate', + getRoute: (policyID: string) => `workspace/${policyID}/rateandunit/rate` as const, + }, + WORKSPACE_RATE_AND_UNIT_UNIT: { + route: 'workspace/:policyID/rateandunit/unit', + getRoute: (policyID: string) => `workspace/${policyID}/rateandunit/unit` as const, + }, WORKSPACE_BILLS: { route: 'workspace/:policyID/bills', getRoute: (policyID: string) => `workspace/${policyID}/bills` as const, diff --git a/src/SCREENS.ts b/src/SCREENS.ts index 1d7c77bf129c..1626fdbd1898 100644 --- a/src/SCREENS.ts +++ b/src/SCREENS.ts @@ -146,7 +146,6 @@ const SCREENS = { PARTICIPANTS: 'Money_Request_Participants', CONFIRMATION: 'Money_Request_Confirmation', CURRENCY: 'Money_Request_Currency', - DATE: 'Money_Request_Date', CATEGORY: 'Money_Request_Category', MERCHANT: 'Money_Request_Merchant', WAYPOINT: 'Money_Request_Waypoint', @@ -198,6 +197,8 @@ const SCREENS = { CARD: 'Workspace_Card', REIMBURSE: 'Workspace_Reimburse', RATE_AND_UNIT: 'Workspace_RateAndUnit', + RATE_AND_UNIT_RATE: 'Workspace_RateAndUnit_Rate', + RATE_AND_UNIT_UNIT: 'Workspace_RateAndUnit_Unit', BILLS: 'Workspace_Bills', INVOICES: 'Workspace_Invoices', TRAVEL: 'Workspace_Travel', diff --git a/src/components/AmountForm.tsx b/src/components/AmountForm.tsx new file mode 100644 index 000000000000..4214d804af06 --- /dev/null +++ b/src/components/AmountForm.tsx @@ -0,0 +1,241 @@ +import type {ForwardedRef} from 'react'; +import React, {forwardRef, useCallback, useEffect, useMemo, useRef, useState} from 'react'; +import {View} from 'react-native'; +import type {NativeSyntheticEvent, TextInput, TextInputSelectionChangeEventData} from 'react-native'; +import useLocalize from '@hooks/useLocalize'; +import useThemeStyles from '@hooks/useThemeStyles'; +import * as Browser from '@libs/Browser'; +import * as CurrencyUtils from '@libs/CurrencyUtils'; +import * as DeviceCapabilities from '@libs/DeviceCapabilities'; +import getOperatingSystem from '@libs/getOperatingSystem'; +import * as MoneyRequestUtils from '@libs/MoneyRequestUtils'; +import CONST from '@src/CONST'; +import BigNumberPad from './BigNumberPad'; +import FormHelpMessage from './FormHelpMessage'; +import TextInputWithCurrencySymbol from './TextInputWithCurrencySymbol'; + +type AmountFormProps = { + /** Amount supplied by the FormProvider */ + value?: string; + + /** Currency supplied by user */ + currency?: string; + + /** Tells how many extra decimal digits are allowed. Default is 0. */ + extraDecimals?: number; + + /** Error to display at the bottom of the component */ + errorText?: string; + + /** Callback to update the amount in the FormProvider */ + onInputChange?: (value: string) => void; + + /** Fired when back button pressed, navigates to currency selection page */ + onCurrencyButtonPress?: () => void; +}; + +/** + * Returns the new selection object based on the updated amount's length + */ +const getNewSelection = (oldSelection: {start: number; end: number}, prevLength: number, newLength: number) => { + const cursorPosition = oldSelection.end + (newLength - prevLength); + return {start: cursorPosition, end: cursorPosition}; +}; + +const AMOUNT_VIEW_ID = 'amountView'; +const NUM_PAD_CONTAINER_VIEW_ID = 'numPadContainerView'; +const NUM_PAD_VIEW_ID = 'numPadView'; + +function AmountForm( + {value: amount, currency = CONST.CURRENCY.USD, extraDecimals = 0, errorText, onInputChange, onCurrencyButtonPress}: AmountFormProps, + forwardedRef: ForwardedRef, +) { + const styles = useThemeStyles(); + const {toLocaleDigit, numberFormat} = useLocalize(); + + const textInput = useRef(null); + + const decimals = CurrencyUtils.getCurrencyDecimals(currency) + extraDecimals; + const currentAmount = useMemo(() => (typeof amount === 'string' ? amount : ''), [amount]); + + const [shouldUpdateSelection, setShouldUpdateSelection] = useState(true); + + const [selection, setSelection] = useState({ + start: currentAmount.length, + end: currentAmount.length, + }); + + const forwardDeletePressedRef = useRef(false); + + /** + * Event occurs when a user presses a mouse button over an DOM element. + */ + const focusTextInput = (event: React.MouseEvent, ids: string[]) => { + const relatedTargetId = (event.nativeEvent?.target as HTMLElement | null)?.id ?? ''; + if (!ids.includes(relatedTargetId)) { + return; + } + event.preventDefault(); + if (!textInput.current) { + return; + } + if (!textInput.current.isFocused()) { + textInput.current.focus(); + } + }; + + /** + * Sets the selection and the amount accordingly to the value passed to the input + * @param newAmount - Changed amount from user input + */ + const setNewAmount = useCallback( + (newAmount: string) => { + // Remove spaces from the newAmount value because Safari on iOS adds spaces when pasting a copied value + // More info: https://github.com/Expensify/App/issues/16974 + const newAmountWithoutSpaces = MoneyRequestUtils.stripSpacesFromAmount(newAmount); + // Use a shallow copy of selection to trigger setSelection + // More info: https://github.com/Expensify/App/issues/16385 + if (!MoneyRequestUtils.validateAmount(newAmountWithoutSpaces, decimals)) { + setSelection((prevSelection) => ({...prevSelection})); + return; + } + + const strippedAmount = MoneyRequestUtils.stripCommaFromAmount(newAmountWithoutSpaces); + const isForwardDelete = currentAmount.length > strippedAmount.length && forwardDeletePressedRef.current; + setSelection((prevSelection) => getNewSelection(prevSelection, isForwardDelete ? strippedAmount.length : currentAmount.length, strippedAmount.length)); + onInputChange?.(strippedAmount); + }, + [currentAmount, decimals, onInputChange], + ); + + // Modifies the amount to match the decimals for changed currency. + useEffect(() => { + // If the changed currency supports decimals, we can return + if (MoneyRequestUtils.validateAmount(currentAmount, decimals)) { + return; + } + + // If the changed currency doesn't support decimals, we can strip the decimals + setNewAmount(MoneyRequestUtils.stripDecimalsFromAmount(currentAmount)); + + // we want to update only when decimals change (setNewAmount also changes when decimals change). + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [decimals]); + + /** + * Update amount with number or Backspace pressed for BigNumberPad. + * Validate new amount with decimal number regex up to 6 digits and 2 decimal digit to enable Next button + */ + const updateAmountNumberPad = useCallback( + (key: string) => { + if (shouldUpdateSelection && !textInput.current?.isFocused()) { + textInput.current?.focus(); + } + // Backspace button is pressed + if (key === '<' || key === 'Backspace') { + if (currentAmount.length > 0) { + const selectionStart = selection.start === selection.end ? selection.start - 1 : selection.start; + const newAmount = `${currentAmount.substring(0, selectionStart)}${currentAmount.substring(selection.end)}`; + setNewAmount(MoneyRequestUtils.addLeadingZero(newAmount)); + } + return; + } + const newAmount = MoneyRequestUtils.addLeadingZero(`${currentAmount.substring(0, selection.start)}${key}${currentAmount.substring(selection.end)}`); + setNewAmount(newAmount); + }, + [currentAmount, selection, shouldUpdateSelection, setNewAmount], + ); + + /** + * Update long press value, to remove items pressing on < + * + * @param value - Changed text from user input + */ + const updateLongPressHandlerState = useCallback((value: boolean) => { + setShouldUpdateSelection(!value); + if (!value && !textInput.current?.isFocused()) { + textInput.current?.focus(); + } + }, []); + + /** + * Input handler to check for a forward-delete key (or keyboard shortcut) press. + */ + const textInputKeyPress = (event: NativeSyntheticEvent) => { + const key = event.nativeEvent.key.toLowerCase(); + if (Browser.isMobileSafari() && key === CONST.PLATFORM_SPECIFIC_KEYS.CTRL.DEFAULT) { + // Optimistically anticipate forward-delete on iOS Safari (in cases where the Mac Accessiblity keyboard is being + // used for input). If the Control-D shortcut doesn't get sent, the ref will still be reset on the next key press. + forwardDeletePressedRef.current = true; + return; + } + // Control-D on Mac is a keyboard shortcut for forward-delete. See https://support.apple.com/en-us/HT201236 for Mac keyboard shortcuts. + // Also check for the keyboard shortcut on iOS in cases where a hardware keyboard may be connected to the device. + const operatingSystem = getOperatingSystem() as string | null; + const allowedOS: string[] = [CONST.OS.MAC_OS, CONST.OS.IOS]; + forwardDeletePressedRef.current = key === 'delete' || (allowedOS.includes(operatingSystem ?? '') && event.nativeEvent.ctrlKey && key === 'd'); + }; + + const formattedAmount = MoneyRequestUtils.replaceAllDigits(currentAmount, toLocaleDigit); + const canUseTouchScreen = DeviceCapabilities.canUseTouchScreen(); + + return ( + <> + focusTextInput(event, [AMOUNT_VIEW_ID])} + style={[styles.moneyRequestAmountContainer, styles.flex1, styles.flexRow, styles.w100, styles.alignItemsCenter, styles.justifyContentCenter]} + > + { + if (typeof forwardedRef === 'function') { + forwardedRef(ref); + } else if (forwardedRef && 'current' in forwardedRef) { + // eslint-disable-next-line no-param-reassign + forwardedRef.current = ref; + } + textInput.current = ref; + }} + selectedCurrencyCode={currency} + selection={selection} + onSelectionChange={(e: NativeSyntheticEvent) => { + if (!shouldUpdateSelection) { + return; + } + setSelection(e.nativeEvent.selection); + }} + onKeyPress={textInputKeyPress} + /> + {!!errorText && ( + + )} + + {canUseTouchScreen ? ( + focusTextInput(event, [NUM_PAD_CONTAINER_VIEW_ID, NUM_PAD_VIEW_ID])} + style={[styles.w100, styles.justifyContentEnd, styles.pageWrapper, styles.pt0]} + id={NUM_PAD_CONTAINER_VIEW_ID} + > + + + ) : null} + + ); +} + +AmountForm.displayName = 'AmountForm'; + +export default forwardRef(AmountForm); diff --git a/src/components/BigNumberPad.tsx b/src/components/BigNumberPad.tsx index 80f7794d0ad3..de43bf548a98 100644 --- a/src/components/BigNumberPad.tsx +++ b/src/components/BigNumberPad.tsx @@ -17,7 +17,7 @@ type BigNumberPadProps = { id?: string; /** Whether long press is disabled */ - isLongPressDisabled: boolean; + isLongPressDisabled?: boolean; }; const padNumbers = [ diff --git a/src/components/CustomStatusBarAndBackground/index.tsx b/src/components/CustomStatusBarAndBackground/index.tsx index 42ea96fe41bb..c653fec73e91 100644 --- a/src/components/CustomStatusBarAndBackground/index.tsx +++ b/src/components/CustomStatusBarAndBackground/index.tsx @@ -116,15 +116,17 @@ function CustomStatusBarAndBackground({isNested = false}: CustomStatusBarAndBack return () => navigationRef.removeListener('state', listener); }, [isDisabled, theme.appBG, updateStatusBarStyle]); - // Update the global background (on web) everytime the theme changes. + // Update the global background and status bar style (on web) everytime the theme changes. // The background of the html element needs to be updated, otherwise you will see a big contrast when resizing the window or when the keyboard is open on iOS web. + // The status bar style needs to be updated when the user changes the theme, otherwise, the status bar will not change its color (mWeb iOS). useEffect(() => { if (isDisabled) { return; } updateGlobalBackgroundColor(theme); - }, [isDisabled, theme]); + updateStatusBarStyle(); + }, [isDisabled, theme, updateStatusBarStyle]); if (isDisabled) { return null; diff --git a/src/components/EmojiPicker/EmojiPickerMenu/index.js b/src/components/EmojiPicker/EmojiPickerMenu/index.js index f336fe659074..b08d2106ff93 100755 --- a/src/components/EmojiPicker/EmojiPickerMenu/index.js +++ b/src/components/EmojiPicker/EmojiPickerMenu/index.js @@ -109,6 +109,9 @@ function EmojiPickerMenu({forwardedRef, onEmojiSelected, activeEmoji}) { initialFocusedIndex: -1, disableCyclicTraversal: true, onFocusedIndexChange, + disableHorizontalKeys: isFocused, + // We pass true without checking visibility of the component because if the popover is not visible this picker won't be mounted + isActive: true, }); const filterEmojis = _.throttle((searchTerm) => { diff --git a/src/components/FeatureList.js b/src/components/FeatureList.tsx similarity index 66% rename from src/components/FeatureList.js rename to src/components/FeatureList.tsx index 14b807aed85e..53f42956d834 100644 --- a/src/components/FeatureList.js +++ b/src/components/FeatureList.tsx @@ -1,60 +1,61 @@ -import PropTypes from 'prop-types'; import React from 'react'; import {View} from 'react-native'; -import _ from 'underscore'; +import type {StyleProp, ViewStyle} from 'react-native'; import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; -import stylePropTypes from '@styles/stylePropTypes'; import variables from '@styles/variables'; +import type {TranslationPaths} from '@src/languages/types'; +import type IconAsset from '@src/types/utils/IconAsset'; import Button from './Button'; +import type DotLottieAnimation from './LottieAnimations/types'; import MenuItem from './MenuItem'; -import menuItemPropTypes from './menuItemPropTypes'; import Section from './Section'; -const propTypes = { +type FeatureListItem = { + icon: IconAsset; + translationKey: TranslationPaths; +}; + +type FeatureListProps = { /** The text to display in the title of the section */ - title: PropTypes.string.isRequired, + title: string; /** The text to display in the subtitle of the section */ - subtitle: PropTypes.string, + subtitle?: string; /** Text of the call to action button */ - ctaText: PropTypes.string, + ctaText?: string; /** Accessibility label for the call to action button */ - ctaAccessibilityLabel: PropTypes.string, + ctaAccessibilityLabel?: string; /** Action to call on cta button press */ - onCtaPress: PropTypes.func, + onCtaPress?: () => void; /** A list of menuItems representing the feature list. */ - menuItems: PropTypes.arrayOf(PropTypes.shape({...menuItemPropTypes, translationKey: PropTypes.string})).isRequired, + menuItems: FeatureListItem[]; /** The illustration to display in the header. Can be a JSON object representing a Lottie animation. */ - illustration: PropTypes.shape({ - file: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired, - w: PropTypes.number.isRequired, - h: PropTypes.number.isRequired, - }), + illustration: DotLottieAnimation; /** The style passed to the illustration */ - illustrationStyle: stylePropTypes, + illustrationStyle?: StyleProp; /** The background color to apply in the upper half of the screen. */ - illustrationBackgroundColor: PropTypes.string, -}; - -const defaultProps = { - ctaText: '', - ctaAccessibilityLabel: '', - subtitle: '', - onCtaPress: () => {}, - illustration: null, - illustrationBackgroundColor: '', - illustrationStyle: [], + illustrationBackgroundColor?: string; }; -function FeatureList({title, subtitle, ctaText, ctaAccessibilityLabel, onCtaPress, menuItems, illustration, illustrationStyle, illustrationBackgroundColor}) { +function FeatureList({ + title, + subtitle = '', + ctaText = '', + ctaAccessibilityLabel = '', + onCtaPress, + menuItems, + illustration, + illustrationStyle, + illustrationBackgroundColor = '', +}: FeatureListProps) { const styles = useThemeStyles(); const {translate} = useLocalize(); @@ -70,7 +71,7 @@ function FeatureList({title, subtitle, ctaText, ctaAccessibilityLabel, onCtaPres > - {_.map(menuItems, ({translationKey, icon}) => ( + {menuItems.map(({translationKey, icon}) => ( @@ -101,8 +102,7 @@ function FeatureList({title, subtitle, ctaText, ctaAccessibilityLabel, onCtaPres ); } -FeatureList.propTypes = propTypes; -FeatureList.defaultProps = defaultProps; FeatureList.displayName = 'FeatureList'; export default FeatureList; +export type {FeatureListItem}; diff --git a/src/components/Form/FormProvider.tsx b/src/components/Form/FormProvider.tsx index ba0f823fdbad..82a8d0870946 100644 --- a/src/components/Form/FormProvider.tsx +++ b/src/components/Form/FormProvider.tsx @@ -1,7 +1,7 @@ import lodashIsEqual from 'lodash/isEqual'; import type {ForwardedRef, MutableRefObject, ReactNode} from 'react'; import React, {createRef, forwardRef, useCallback, useImperativeHandle, useMemo, useRef, useState} from 'react'; -import type {NativeSyntheticEvent, TextInputSubmitEditingEventData} from 'react-native'; +import type {NativeSyntheticEvent, StyleProp, TextInputSubmitEditingEventData, ViewStyle} from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; import {withOnyx} from 'react-native-onyx'; import * as ValidationUtils from '@libs/ValidationUtils'; @@ -62,6 +62,12 @@ type FormProviderProps = FormProvider /** Should validate function be called when the value of the input is changed */ shouldValidateOnChange?: boolean; + + /** Styles that will be applied to the submit button only */ + submitButtonStyles?: StyleProp; + + /** Whether to apply flex to the submit button */ + submitFlexEnabled?: boolean; }; type FormRef = { diff --git a/src/components/Form/FormWrapper.tsx b/src/components/Form/FormWrapper.tsx index 074069ec3ea7..e8778cf25d58 100644 --- a/src/components/Form/FormWrapper.tsx +++ b/src/components/Form/FormWrapper.tsx @@ -28,6 +28,9 @@ type FormWrapperProps = ChildrenProps & /** Submit button styles */ submitButtonStyles?: StyleProp; + /** Whether to apply flex to the submit button */ + submitFlexEnabled?: boolean; + /** Server side errors keyed by microtime */ errors: Errors; @@ -49,6 +52,7 @@ function FormWrapper({ isSubmitButtonVisible = true, style, submitButtonStyles, + submitFlexEnabled = true, enabledWhenOffline, isSubmitActionDangerous = false, formID, @@ -109,7 +113,7 @@ function FormWrapper({ onSubmit={onSubmit} footerContent={footerContent} onFixTheErrorsLinkPressed={onFixTheErrorsLinkPressed} - containerStyles={[styles.mh0, styles.mt5, styles.flex1, submitButtonStyles]} + containerStyles={[styles.mh0, styles.mt5, submitFlexEnabled ? styles.flex1 : {}, submitButtonStyles]} enabledWhenOffline={enabledWhenOffline} isSubmitActionDangerous={isSubmitActionDangerous} disablePressOnEnter={disablePressOnEnter} @@ -134,6 +138,7 @@ function FormWrapper({ styles.mh0, styles.mt5, submitButtonStyles, + submitFlexEnabled, submitButtonText, shouldHideFixErrorsAlert, onFixTheErrorsLinkPressed, diff --git a/src/components/Form/types.ts b/src/components/Form/types.ts index 353a6927caf7..5ade522e6d7f 100644 --- a/src/components/Form/types.ts +++ b/src/components/Form/types.ts @@ -1,6 +1,7 @@ import type {ComponentProps, FocusEvent, Key, MutableRefObject, ReactNode, Ref} from 'react'; import type {GestureResponderEvent, NativeSyntheticEvent, StyleProp, TextInputFocusEventData, TextInputSubmitEditingEventData, ViewStyle} from 'react-native'; import type AddressSearch from '@components/AddressSearch'; +import type AmountForm from '@components/AmountForm'; import type AmountTextInput from '@components/AmountTextInput'; import type CheckboxWithLabel from '@components/CheckboxWithLabel'; import type Picker from '@components/Picker'; @@ -17,7 +18,7 @@ import type {BaseForm, FormValueType} from '@src/types/onyx/Form'; * TODO: Add remaining inputs here once these components are migrated to Typescript: * CountrySelector | StatePicker | DatePicker | EmojiPickerButtonDropdown | RoomNameInput | ValuePicker */ -type ValidInputs = typeof TextInput | typeof AmountTextInput | typeof SingleChoiceQuestion | typeof CheckboxWithLabel | typeof Picker | typeof AddressSearch; +type ValidInputs = typeof TextInput | typeof AmountTextInput | typeof SingleChoiceQuestion | typeof CheckboxWithLabel | typeof Picker | typeof AddressSearch | typeof AmountForm; type ValueTypeKey = 'string' | 'boolean' | 'date'; diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/CodeRenderer.tsx b/src/components/HTMLEngineProvider/HTMLRenderers/CodeRenderer.tsx index d1c11dc12ed5..788db4ea4de2 100644 --- a/src/components/HTMLEngineProvider/HTMLRenderers/CodeRenderer.tsx +++ b/src/components/HTMLEngineProvider/HTMLRenderers/CodeRenderer.tsx @@ -1,5 +1,4 @@ import React from 'react'; -import type {TextStyle} from 'react-native'; import {splitBoxModelStyle} from 'react-native-render-html'; import type {CustomRendererProps, TPhrasing, TText} from 'react-native-render-html'; import * as HTMLEngineUtils from '@components/HTMLEngineProvider/htmlEngineUtils'; @@ -41,7 +40,7 @@ function CodeRenderer({TDefaultRenderer, key, style, ...defaultRendererProps}: C return ( = { +const DotLottieAnimations = { ExpensifyLounge: { file: require('@assets/animations/ExpensifyLounge.lottie'), w: 1920, @@ -26,6 +27,7 @@ const DotLottieAnimations: Record = { file: require('@assets/animations/PreferencesDJ.lottie'), w: 375, h: 240, + backgroundColor: colors.blue500, }, ReviewingBankInfo: { file: require('@assets/animations/ReviewingBankInfo.lottie'), @@ -36,6 +38,7 @@ const DotLottieAnimations: Record = { file: require('@assets/animations/WorkspacePlanet.lottie'), w: 375, h: 240, + backgroundColor: colors.pink800, }, SaveTheWorld: { file: require('@assets/animations/SaveTheWorld.lottie'), @@ -46,6 +49,7 @@ const DotLottieAnimations: Record = { file: require('@assets/animations/Safe.lottie'), w: 625, h: 400, + backgroundColor: colors.ice500, }, Magician: { file: require('@assets/animations/Magician.lottie'), @@ -61,7 +65,8 @@ const DotLottieAnimations: Record = { file: require('@assets/animations/Coin.lottie'), w: 375, h: 240, + backgroundColor: colors.yellow600, }, -}; +} satisfies Record; export default DotLottieAnimations; diff --git a/src/components/LottieAnimations/types.ts b/src/components/LottieAnimations/types.ts index 6000b9f853f0..e1a6e0b66c74 100644 --- a/src/components/LottieAnimations/types.ts +++ b/src/components/LottieAnimations/types.ts @@ -1,9 +1,11 @@ import type {LottieViewProps} from 'lottie-react-native'; +import type {ColorValue} from '@styles/utils/types'; type DotLottieAnimation = { file: LottieViewProps['source']; w: number; h: number; + backgroundColor?: ColorValue; }; export default DotLottieAnimation; diff --git a/src/components/MenuItem.tsx b/src/components/MenuItem.tsx index 6163fa116561..faecf55a0c4a 100644 --- a/src/components/MenuItem.tsx +++ b/src/components/MenuItem.tsx @@ -57,7 +57,7 @@ type NoIcon = { icon?: undefined; }; -type MenuItemProps = (IconProps | AvatarProps | NoIcon) & { +type MenuItemBaseProps = { /** Function to fire when component is pressed */ onPress?: (event: GestureResponderEvent | KeyboardEvent) => void | Promise; @@ -233,6 +233,8 @@ type MenuItemProps = (IconProps | AvatarProps | NoIcon) & { isPaneMenu?: boolean; }; +type MenuItemProps = (IconProps | AvatarProps | NoIcon) & MenuItemBaseProps; + function MenuItem( { interactive = true, @@ -625,5 +627,5 @@ function MenuItem( MenuItem.displayName = 'MenuItem'; -export type {MenuItemProps}; +export type {IconProps, AvatarProps, NoIcon, MenuItemBaseProps, MenuItemProps}; export default forwardRef(MenuItem); diff --git a/src/components/MoneyRequestConfirmationList.js b/src/components/MoneyRequestConfirmationList.js index da9838701fa5..7608447a213e 100755 --- a/src/components/MoneyRequestConfirmationList.js +++ b/src/components/MoneyRequestConfirmationList.js @@ -703,11 +703,15 @@ function MoneyRequestConfirmationList(props) { style={[styles.moneyRequestMenuItem]} titleStyle={styles.flex1} onPress={() => { - if (props.isEditingSplitBill) { - Navigation.navigate(ROUTES.EDIT_SPLIT_BILL.getRoute(props.reportID, props.reportActionID, CONST.EDIT_REQUEST_FIELD.DATE)); - return; - } - Navigation.navigate(ROUTES.MONEY_REQUEST_DATE.getRoute(props.iouType, props.reportID)); + Navigation.navigate( + ROUTES.MONEY_REQUEST_STEP_DATE.getRoute( + CONST.IOU.ACTION.EDIT, + props.iouType, + transaction.transactionID, + props.reportID, + Navigation.getActiveRouteWithoutParams(), + ), + ); }} disabled={didConfirm} interactive={!props.isReadOnly} diff --git a/src/components/MoneyTemporaryForRefactorRequestConfirmationList.js b/src/components/MoneyTemporaryForRefactorRequestConfirmationList.js index a2f79d2696b8..418ba97a70b9 100755 --- a/src/components/MoneyTemporaryForRefactorRequestConfirmationList.js +++ b/src/components/MoneyTemporaryForRefactorRequestConfirmationList.js @@ -21,6 +21,7 @@ import * as OptionsListUtils from '@libs/OptionsListUtils'; import * as PolicyUtils from '@libs/PolicyUtils'; import * as ReceiptUtils from '@libs/ReceiptUtils'; import * as ReportUtils from '@libs/ReportUtils'; +import playSound, {SOUNDS} from '@libs/Sound'; import * as TransactionUtils from '@libs/TransactionUtils'; import * as IOU from '@userActions/IOU'; import CONST from '@src/CONST'; @@ -549,6 +550,7 @@ function MoneyTemporaryForRefactorRequestConfirmationList({ return; } + playSound(SOUNDS.DONE); setDidConfirm(true); onConfirm(selectedParticipants); } @@ -790,11 +792,9 @@ function MoneyTemporaryForRefactorRequestConfirmationList({ style={[styles.moneyRequestMenuItem]} titleStyle={styles.flex1} onPress={() => { - if (isEditingSplitBill) { - Navigation.navigate(ROUTES.EDIT_SPLIT_BILL.getRoute(reportID, reportActionID, CONST.EDIT_REQUEST_FIELD.DATE)); - return; - } - Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_DATE.getRoute(iouType, transaction.transactionID, reportID, Navigation.getActiveRouteWithoutParams())); + Navigation.navigate( + ROUTES.MONEY_REQUEST_STEP_DATE.getRoute(CONST.IOU.ACTION.CREATE, iouType, transaction.transactionID, reportID, Navigation.getActiveRouteWithoutParams()), + ); }} disabled={didConfirm} interactive={!isReadOnly} diff --git a/src/components/OptionsSelector/BaseOptionsSelector.js b/src/components/OptionsSelector/BaseOptionsSelector.js index cb6a2dcbe722..4396f4329606 100755 --- a/src/components/OptionsSelector/BaseOptionsSelector.js +++ b/src/components/OptionsSelector/BaseOptionsSelector.js @@ -114,7 +114,10 @@ class BaseOptionsSelector extends Component { componentDidUpdate(prevProps, prevState) { if (prevState.disableEnterShortCut !== this.state.disableEnterShortCut) { if (this.state.disableEnterShortCut) { - this.unsubscribeEnter(); + if (this.unsubscribeEnter) { + this.unsubscribeEnter(); + this.unsubscribeEnter = undefined; + } } else { this.subscribeToEnterShortcut(); } @@ -290,6 +293,9 @@ class BaseOptionsSelector extends Component { } subscribeToEnterShortcut() { + if (this.unsubscribeEnter) { + this.unsubscribeEnter(); + } const enterConfig = CONST.KEYBOARD_SHORTCUTS.ENTER; this.unsubscribeEnter = KeyboardShortcut.subscribe( enterConfig.shortcutKey, @@ -302,6 +308,9 @@ class BaseOptionsSelector extends Component { } subscribeToCtrlEnterShortcut() { + if (this.unsubscribeCTRLEnter) { + this.unsubscribeCTRLEnter(); + } const CTRLEnterConfig = CONST.KEYBOARD_SHORTCUTS.CTRL_ENTER; this.unsubscribeCTRLEnter = KeyboardShortcut.subscribe( CTRLEnterConfig.shortcutKey, @@ -327,10 +336,12 @@ class BaseOptionsSelector extends Component { unSubscribeFromKeyboardShortcut() { if (this.unsubscribeEnter) { this.unsubscribeEnter(); + this.unsubscribeEnter = undefined; } if (this.unsubscribeCTRLEnter) { this.unsubscribeCTRLEnter(); + this.unsubscribeCTRLEnter = undefined; } } diff --git a/src/components/PDFView/index.native.js b/src/components/PDFView/index.native.js index bfdb80131aa6..3802fe7a2ea6 100644 --- a/src/components/PDFView/index.native.js +++ b/src/components/PDFView/index.native.js @@ -1,25 +1,21 @@ -import React, {Component} from 'react'; +import React, {useCallback, useEffect, useState} from 'react'; import {View} from 'react-native'; import PDF from 'react-native-pdf'; import FullScreenLoadingIndicator from '@components/FullscreenLoadingIndicator'; import KeyboardAvoidingView from '@components/KeyboardAvoidingView'; import PressableWithoutFeedback from '@components/Pressable/PressableWithoutFeedback'; import Text from '@components/Text'; -import withKeyboardState, {keyboardStatePropTypes} from '@components/withKeyboardState'; -import withLocalize from '@components/withLocalize'; -import withStyleUtils, {withStyleUtilsPropTypes} from '@components/withStyleUtils'; -import withThemeStyles, {withThemeStylesPropTypes} from '@components/withThemeStyles'; -import withWindowDimensions from '@components/withWindowDimensions'; -import compose from '@libs/compose'; +import useKeyboardState from '@hooks/useKeyboardState'; +import useLocalize from '@hooks/useLocalize'; +import useStyleUtils from '@hooks/useStyleUtils'; +import useThemeStyles from '@hooks/useThemeStyles'; +import useWindowDimensions from '@hooks/useWindowDimensions'; import CONST from '@src/CONST'; import PDFPasswordForm from './PDFPasswordForm'; import {defaultProps, propTypes as pdfViewPropTypes} from './pdfViewPropTypes'; const propTypes = { ...pdfViewPropTypes, - ...keyboardStatePropTypes, - ...withThemeStylesPropTypes, - ...withStyleUtilsPropTypes, }; /** @@ -36,41 +32,24 @@ const propTypes = { * so that PDFPasswordForm doesn't bounce when react-native-pdf/PDF * is (temporarily) rendered. */ -class PDFView extends Component { - constructor(props) { - super(props); - this.state = { - shouldRequestPassword: false, - shouldAttemptPDFLoad: true, - shouldShowLoadingIndicator: true, - isPasswordInvalid: false, - failedToLoadPDF: false, - successToLoadPDF: false, - password: '', - }; - this.initiatePasswordChallenge = this.initiatePasswordChallenge.bind(this); - this.attemptPDFLoadWithPassword = this.attemptPDFLoadWithPassword.bind(this); - this.finishPDFLoad = this.finishPDFLoad.bind(this); - this.handleFailureToLoadPDF = this.handleFailureToLoadPDF.bind(this); - } - - componentDidUpdate() { - this.props.onToggleKeyboard(this.props.isKeyboardShown); - } - - handleFailureToLoadPDF(error) { - if (error.message.match(/password/i)) { - this.initiatePasswordChallenge(); - return; - } - this.setState({ - failedToLoadPDF: true, - shouldAttemptPDFLoad: false, - shouldRequestPassword: false, - shouldShowLoadingIndicator: false, - }); - } +function PDFView({onToggleKeyboard, onLoadComplete, fileName, onPress, isFocused, onScaleChanged, sourceURL, errorLabelStyles}) { + const [shouldRequestPassword, setShouldRequestPassword] = useState(false); + const [shouldAttemptPDFLoad, setShouldAttemptPDFLoad] = useState(true); + const [shouldShowLoadingIndicator, setShouldShowLoadingIndicator] = useState(true); + const [isPasswordInvalid, setIsPasswordInvalid] = useState(false); + const [failedToLoadPDF, setFailedToLoadPDF] = useState(false); + const [successToLoadPDF, setSuccessToLoadPDF] = useState(false); + const [password, setPassword] = useState(''); + const {windowWidth, windowHeight, isSmallScreenWidth} = useWindowDimensions(); + const {translate} = useLocalize(); + const themeStyles = useThemeStyles(); + const {isKeyboardShown} = useKeyboardState(); + const StyleUtils = useStyleUtils(); + + useEffect(() => { + onToggleKeyboard(isKeyboardShown); + }); /** * Initiate password challenge if message received from react-native-pdf/PDF @@ -80,100 +59,101 @@ class PDFView extends Component { * Note that the message doesn't specify whether the password is simply empty or * invalid. */ - initiatePasswordChallenge() { - this.setState({shouldShowLoadingIndicator: false}); + + const initiatePasswordChallenge = useCallback(() => { + setShouldShowLoadingIndicator(false); // Render password form, and don't render PDF and loading indicator. - this.setState({ - shouldRequestPassword: true, - shouldAttemptPDFLoad: false, - }); + setShouldRequestPassword(true); + setShouldAttemptPDFLoad(false); // The message provided by react-native-pdf doesn't indicate whether this // is an initial password request or if the password is invalid. So we just assume // that if a password was already entered then it's an invalid password error. - if (this.state.password) { - this.setState({isPasswordInvalid: true}); + if (password) { + setIsPasswordInvalid(true); } - } + }, [password]); + + const handleFailureToLoadPDF = (error) => { + if (error.message.match(/password/i)) { + initiatePasswordChallenge(); + return; + } + setFailedToLoadPDF(true); + setShouldShowLoadingIndicator(false); + setShouldRequestPassword(false); + setShouldAttemptPDFLoad(false); + }; /** * When the password is submitted via PDFPasswordForm, save the password * in state and attempt to load the PDF. Also show the loading indicator * since react-native-pdf/PDF will need to reload the PDF. * - * @param {String} password Password submitted via PDFPasswordForm + * @param {String} pdfPassword Password submitted via PDFPasswordForm */ - attemptPDFLoadWithPassword(password) { + const attemptPDFLoadWithPassword = (pdfPassword) => { // Render react-native-pdf/PDF so that it can validate the password. // Note that at this point in the password challenge, shouldRequestPassword is true. // Thus react-native-pdf/PDF will be rendered - but not visible. - this.setState({ - password, - shouldAttemptPDFLoad: true, - shouldShowLoadingIndicator: true, - }); - } - + setPassword(pdfPassword); + setShouldAttemptPDFLoad(true); + setShouldShowLoadingIndicator(true); + }; /** * After the PDF is successfully loaded hide PDFPasswordForm and the loading * indicator. */ - finishPDFLoad() { - this.setState({ - shouldRequestPassword: false, - shouldShowLoadingIndicator: false, - successToLoadPDF: true, - }); - this.props.onLoadComplete(); - } + const finishPDFLoad = () => { + setShouldRequestPassword(false); + setShouldShowLoadingIndicator(false); + setSuccessToLoadPDF(true); + onLoadComplete(); + }; - renderPDFView() { - const pdfStyles = [this.props.themeStyles.imageModalPDF, this.props.StyleUtils.getWidthAndHeightStyle(this.props.windowWidth, this.props.windowHeight)]; + function renderPDFView() { + const pdfStyles = [themeStyles.imageModalPDF, StyleUtils.getWidthAndHeightStyle(windowWidth, windowHeight)]; // If we haven't yet successfully validated the password and loaded the PDF, // then we need to hide the react-native-pdf/PDF component so that PDFPasswordForm // is positioned nicely. We're specifically hiding it because we still need to render // the PDF component so that it can validate the password. - if (this.state.shouldRequestPassword) { - pdfStyles.push(this.props.themeStyles.invisible); + if (shouldRequestPassword) { + pdfStyles.push(themeStyles.invisible); } - const containerStyles = - this.state.shouldRequestPassword && this.props.isSmallScreenWidth - ? [this.props.themeStyles.w100, this.props.themeStyles.flex1] - : [this.props.themeStyles.alignItemsCenter, this.props.themeStyles.flex1]; + const containerStyles = shouldRequestPassword && isSmallScreenWidth ? [themeStyles.w100, themeStyles.flex1] : [themeStyles.alignItemsCenter, themeStyles.flex1]; return ( - {this.state.failedToLoadPDF && ( - - {this.props.translate('attachmentView.failedToLoadPDF')} + {failedToLoadPDF && ( + + {translate('attachmentView.failedToLoadPDF')} )} - {this.state.shouldAttemptPDFLoad && ( + {shouldAttemptPDFLoad && ( } - source={{uri: this.props.sourceURL}} + source={{uri: sourceURL}} style={pdfStyles} - onError={this.handleFailureToLoadPDF} - password={this.state.password} - onLoadComplete={this.finishPDFLoad} - onPageSingleTap={this.props.onPress} - onScaleChanged={this.props.onScaleChanged} + onError={handleFailureToLoadPDF} + password={password} + onLoadComplete={finishPDFLoad} + onPageSingleTap={onPress} + onScaleChanged={onScaleChanged} /> )} - - {this.state.shouldRequestPassword && ( - + {shouldRequestPassword && ( + this.setState({isPasswordInvalid: false})} - isPasswordInvalid={this.state.isPasswordInvalid} - shouldShowLoadingIndicator={this.state.shouldShowLoadingIndicator} + isFocused={isFocused} + onSubmit={attemptPDFLoadWithPassword} + onPasswordUpdated={() => setIsPasswordInvalid(false)} + isPasswordInvalid={isPasswordInvalid} + shouldShowLoadingIndicator={shouldShowLoadingIndicator} /> )} @@ -181,23 +161,22 @@ class PDFView extends Component { ); } - render() { - return this.props.onPress && !this.state.successToLoadPDF ? ( - - {this.renderPDFView()} - - ) : ( - this.renderPDFView() - ); - } + return onPress && !successToLoadPDF ? ( + + {renderPDFView()} + + ) : ( + renderPDFView() + ); } +PDFView.displayName = 'PDFView'; PDFView.propTypes = propTypes; PDFView.defaultProps = defaultProps; -export default compose(withWindowDimensions, withKeyboardState, withLocalize, withThemeStyles, withStyleUtils)(PDFView); +export default PDFView; diff --git a/src/components/ReportActionItem/MoneyRequestView.tsx b/src/components/ReportActionItem/MoneyRequestView.tsx index f0956da948c9..6b16f272e4c8 100644 --- a/src/components/ReportActionItem/MoneyRequestView.tsx +++ b/src/components/ReportActionItem/MoneyRequestView.tsx @@ -20,17 +20,19 @@ import type {ViolationField} from '@hooks/useViolations'; import useWindowDimensions from '@hooks/useWindowDimensions'; import * as CardUtils from '@libs/CardUtils'; import * as CurrencyUtils from '@libs/CurrencyUtils'; -import Navigation from '@libs/Navigation/Navigation'; import * as OptionsListUtils from '@libs/OptionsListUtils'; import * as PolicyUtils from '@libs/PolicyUtils'; import * as ReceiptUtils from '@libs/ReceiptUtils'; import * as ReportActionsUtils from '@libs/ReportActionsUtils'; import * as ReportUtils from '@libs/ReportUtils'; import * as TransactionUtils from '@libs/TransactionUtils'; +import ViolationsUtils from '@libs/Violations/ViolationsUtils'; +import Navigation from '@navigation/Navigation'; import AnimatedEmptyStateBackground from '@pages/home/report/AnimatedEmptyStateBackground'; import * as IOU from '@userActions/IOU'; import * as Transaction from '@userActions/Transaction'; import CONST from '@src/CONST'; +import type {TranslationPaths} from '@src/languages/types'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import type * as OnyxTypes from '@src/types/onyx'; @@ -196,6 +198,43 @@ function MoneyRequestView({ const pendingAction = transaction?.pendingAction; const getPendingFieldAction = (fieldPath: TransactionPendingFieldsKey) => transaction?.pendingFields?.[fieldPath] ?? pendingAction; + const getErrorForField = useCallback( + (field: ViolationField) => { + // Checks applied when creating a new money request + // NOTE: receipt field can return multiple violations, so we need to handle it separately + const fieldChecks: Partial> = { + amount: { + isError: transactionAmount === 0, + translationPath: 'common.error.enterAmount', + }, + merchant: { + isError: !isSettled && !isCancelled && isPolicyExpenseChat && isEmptyMerchant, + translationPath: 'common.error.enterMerchant', + }, + date: { + isError: transactionDate === '', + translationPath: 'common.error.enterDate', + }, + }; + + const {isError, translationPath} = fieldChecks[field] ?? {}; + + // Return form errors if there are any + if (hasErrors && isError && translationPath) { + return translate(translationPath); + } + + // Return violations if there are any + if (canUseViolations && hasViolations(field)) { + const violations = getViolationsForField(field); + return ViolationsUtils.getViolationTranslation(violations[0], translate); + } + + return ''; + }, + [transactionAmount, isSettled, isCancelled, isPolicyExpenseChat, isEmptyMerchant, transactionDate, hasErrors, canUseViolations, hasViolations, translate, getViolationsForField], + ); + return ( @@ -253,10 +292,9 @@ function MoneyRequestView({ interactive={canEditAmount} shouldShowRightIcon={canEditAmount} onPress={() => Navigation.navigate(ROUTES.EDIT_REQUEST.getRoute(report.reportID, CONST.EDIT_REQUEST_FIELD.AMOUNT))} - brickRoadIndicator={hasViolations('amount') || (hasErrors && transactionAmount === 0) ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined} - error={hasErrors && transactionAmount === 0 ? translate('common.error.enterAmount') : ''} + brickRoadIndicator={getErrorForField('amount') ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined} + error={getErrorForField('amount')} /> - {canUseViolations && } - {canUseViolations && } {isDistanceRequest ? ( @@ -297,14 +335,9 @@ function MoneyRequestView({ shouldShowRightIcon={canEditMerchant} titleStyle={styles.flex1} onPress={() => Navigation.navigate(ROUTES.EDIT_REQUEST.getRoute(report.reportID, CONST.EDIT_REQUEST_FIELD.MERCHANT))} - brickRoadIndicator={ - hasViolations('merchant') || (!isSettled && !isCancelled && hasErrors && isEmptyMerchant && isPolicyExpenseChat) - ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR - : undefined - } - error={!isSettled && !isCancelled && hasErrors && isPolicyExpenseChat && isEmptyMerchant ? translate('common.error.enterMerchant') : ''} + brickRoadIndicator={hasViolations('merchant') || (hasErrors && isEmptyMerchant && isPolicyExpenseChat) ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined} + error={hasErrors && isPolicyExpenseChat && isEmptyMerchant ? translate('common.error.enterMerchant') : ''} /> - {canUseViolations && } )} @@ -314,11 +347,12 @@ function MoneyRequestView({ interactive={canEditDate} shouldShowRightIcon={canEditDate} titleStyle={styles.flex1} - onPress={() => Navigation.navigate(ROUTES.EDIT_REQUEST.getRoute(report.reportID, CONST.EDIT_REQUEST_FIELD.DATE))} - brickRoadIndicator={hasViolations('date') || (hasErrors && transactionDate === '') ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined} - error={hasErrors && transactionDate === '' ? translate('common.error.enterDate') : ''} + onPress={() => + Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_DATE.getRoute(CONST.IOU.ACTION.EDIT, CONST.IOU.TYPE.REQUEST, transaction?.transactionID ?? '', report.reportID)) + } + brickRoadIndicator={getErrorForField('date') ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined} + error={getErrorForField('date')} /> - {canUseViolations && } {shouldShowCategory && ( @@ -329,9 +363,9 @@ function MoneyRequestView({ shouldShowRightIcon={canEdit} titleStyle={styles.flex1} onPress={() => Navigation.navigate(ROUTES.EDIT_REQUEST.getRoute(report.reportID, CONST.EDIT_REQUEST_FIELD.CATEGORY))} - brickRoadIndicator={hasViolations('category') ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined} + brickRoadIndicator={getErrorForField('category') ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined} + error={getErrorForField('category')} /> - {canUseViolations && } )} {shouldShowTag && ( @@ -345,9 +379,9 @@ function MoneyRequestView({ onPress={() => Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_TAG.getRoute(CONST.IOU.ACTION.EDIT, CONST.IOU.TYPE.REQUEST, transaction?.transactionID ?? '', report.reportID)) } - brickRoadIndicator={hasViolations('tag') ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined} + brickRoadIndicator={getErrorForField('tag') ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined} + error={getErrorForField('tag')} /> - {canUseViolations && } )} {isCardTransaction && ( @@ -369,13 +403,13 @@ function MoneyRequestView({ isOn={!!transactionBillable} onToggle={saveBillable} /> + {getErrorForField('billable') && ( + + )} - {hasViolations('billable') && ( - - )} )} diff --git a/src/components/Section/index.tsx b/src/components/Section/index.tsx index 7737927e5307..33f5f3d2230a 100644 --- a/src/components/Section/index.tsx +++ b/src/components/Section/index.tsx @@ -1,4 +1,5 @@ import React from 'react'; +import type {ReactNode} from 'react'; import type {StyleProp, TextStyle, ViewStyle} from 'react-native'; import {View} from 'react-native'; import type {ValueOf} from 'type-fest'; @@ -65,6 +66,9 @@ type SectionProps = ChildrenProps & { /** Styles to apply to illustration component */ illustrationStyle?: StyleProp; + + /** Overlay content to display on top of animation */ + overlayContent?: () => ReactNode; }; function Section({ @@ -84,13 +88,14 @@ function Section({ illustration, illustrationBackgroundColor, illustrationStyle, + overlayContent, }: SectionProps) { const styles = useThemeStyles(); const theme = useTheme(); const StyleUtils = useStyleUtils(); const {isSmallScreenWidth} = useWindowDimensions(); - const illustrationContainerStyle: StyleProp = StyleUtils.getBackgroundColorStyle(illustrationBackgroundColor ?? theme.appBG); + const illustrationContainerStyle: StyleProp = StyleUtils.getBackgroundColorStyle(illustrationBackgroundColor ?? illustration?.backgroundColor ?? theme.appBG); return ( <> @@ -107,10 +112,12 @@ function Section({ + {overlayContent?.()} )} diff --git a/src/components/SelectionList/BaseSelectionList.tsx b/src/components/SelectionList/BaseSelectionList.tsx index c77e244b4c92..3ac34c758c98 100644 --- a/src/components/SelectionList/BaseSelectionList.tsx +++ b/src/components/SelectionList/BaseSelectionList.tsx @@ -180,9 +180,8 @@ function BaseSelectionList( * Logic to run when a row is selected, either with click/press or keyboard hotkeys. * * @param item - the list item - * @param shouldUnfocusRow - flag to decide if we should unfocus all rows. True when selecting a row with click or press (not keyboard) */ - const selectRow = (item: TItem, shouldUnfocusRow = false) => { + const selectRow = (item: TItem) => { // In single-selection lists we don't care about updating the focused index, because the list is closed after selecting an item if (canSelectMultiple) { if (sections.length > 1) { @@ -191,20 +190,11 @@ function BaseSelectionList( // we focus the first one after all the selected (selected items are always at the top). const selectedOptionsCount = item.isSelected ? flattenedSections.selectedOptions.length - 1 : flattenedSections.selectedOptions.length + 1; - if (!shouldUnfocusRow) { - setFocusedIndex(selectedOptionsCount); - } - if (!item.isSelected) { // If we're selecting an item, scroll to it's position at the top, so we can see it scrollToIndex(Math.max(selectedOptionsCount - 1, 0), true); } } - - if (shouldUnfocusRow) { - // Unfocus all rows when selecting row with click/press - setFocusedIndex(-1); - } } onSelectRow(item); @@ -295,7 +285,7 @@ function BaseSelectionList( isDisabled={isDisabled} showTooltip={showTooltip} canSelectMultiple={canSelectMultiple} - onSelectRow={() => selectRow(item, true)} + onSelectRow={() => selectRow(item)} onDismissError={onDismissError} shouldPreventDefaultFocusOnSelectRow={shouldPreventDefaultFocusOnSelectRow} rightHandSideComponent={rightHandSideComponent} diff --git a/src/components/SettlementButton.tsx b/src/components/SettlementButton.tsx index 80bedc84f069..50bfcd4cc8be 100644 --- a/src/components/SettlementButton.tsx +++ b/src/components/SettlementButton.tsx @@ -5,6 +5,7 @@ import {withOnyx} from 'react-native-onyx'; import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; import * as ReportUtils from '@libs/ReportUtils'; +import playSound, {SOUNDS} from '@libs/Sound'; import * as BankAccounts from '@userActions/BankAccounts'; import * as IOU from '@userActions/IOU'; import * as PaymentMethods from '@userActions/PaymentMethods'; @@ -201,6 +202,7 @@ function SettlementButton({ return; } + playSound(SOUNDS.DONE); onPress(iouPaymentType); }; diff --git a/src/components/TestToolMenu.tsx b/src/components/TestToolMenu.tsx index 410bd5081e25..3766ed71a149 100644 --- a/src/components/TestToolMenu.tsx +++ b/src/components/TestToolMenu.tsx @@ -1,7 +1,6 @@ import React from 'react'; import type {OnyxEntry} from 'react-native-onyx'; import {withOnyx} from 'react-native-onyx'; -import useThemeStyles from '@hooks/useThemeStyles'; import * as ApiUtils from '@libs/ApiUtils'; import compose from '@libs/compose'; import * as Network from '@userActions/Network'; @@ -14,7 +13,6 @@ import Button from './Button'; import {withNetwork} from './OnyxProvider'; import Switch from './Switch'; import TestToolRow from './TestToolRow'; -import Text from './Text'; type TestToolMenuOnyxProps = { /** User object in Onyx */ @@ -29,17 +27,9 @@ const USER_DEFAULT: UserOnyx = {shouldUseStagingServer: undefined, isSubscribedT function TestToolMenu({user = USER_DEFAULT, network}: TestToolMenuProps) { const shouldUseStagingServer = user?.shouldUseStagingServer ?? ApiUtils.isUsingStagingApi(); - const styles = useThemeStyles(); return ( <> - - Test Preferences - - {/* Option to switch between staging and default api endpoints. This enables QA, internal testers and external devs to take advantage of sandbox environments for 3rd party services like Plaid and Onfido. This toggle is not rendered for internal devs as they make environment changes directly to the .env file. */} diff --git a/src/components/TestToolRow.tsx b/src/components/TestToolRow.tsx index 4ed1aa126002..f6ff1787b566 100644 --- a/src/components/TestToolRow.tsx +++ b/src/components/TestToolRow.tsx @@ -14,7 +14,7 @@ type TestToolRowProps = { function TestToolRow({title, children}: TestToolRowProps) { const styles = useThemeStyles(); return ( - + {title} diff --git a/src/components/TextInput/BaseTextInput/index.native.tsx b/src/components/TextInput/BaseTextInput/index.native.tsx index c6429747bf97..9e169ab2464a 100644 --- a/src/components/TextInput/BaseTextInput/index.native.tsx +++ b/src/components/TextInput/BaseTextInput/index.native.tsx @@ -265,11 +265,12 @@ function BaseTextInput( return ( <> void]; @@ -28,6 +29,7 @@ type UseArrowKeyFocusManager = [number, (index: number) => void]; * @param [config.isActive] – Whether the component is ready and should subscribe to KeyboardShortcut * @param [config.itemsPerRow] – The number of items per row. If provided, the arrow keys will move focus horizontally as well as vertically * @param [config.disableCyclicTraversal] – Whether to disable cyclic traversal of the list. If true, the arrow keys will have no effect when the first or last item is focused + * @param [config.disableHorizontalKeys] – Whether to disable the right/left keys */ export default function useArrowKeyFocusManager({ maxIndex, @@ -41,6 +43,7 @@ export default function useArrowKeyFocusManager({ isActive, itemsPerRow, disableCyclicTraversal = false, + disableHorizontalKeys = false, }: Config): UseArrowKeyFocusManager { const allowHorizontalArrowKeys = !!itemsPerRow; const [focusedIndex, setFocusedIndex] = useState(initialFocusedIndex); @@ -52,6 +55,14 @@ export default function useArrowKeyFocusManager({ [isActive, shouldExcludeTextAreaNodes], ); + const horizontalArrowConfig = useMemo( + () => ({ + excludedNodes: shouldExcludeTextAreaNodes ? ['TEXTAREA'] : [], + isActive: isActive && !disableHorizontalKeys, + }), + [isActive, shouldExcludeTextAreaNodes, disableHorizontalKeys], + ); + // eslint-disable-next-line react-hooks/exhaustive-deps useEffect(() => onFocusedIndexChange(focusedIndex), [focusedIndex]); @@ -155,7 +166,7 @@ export default function useArrowKeyFocusManager({ }); }, [allowHorizontalArrowKeys, disableCyclicTraversal, disabledIndexes, maxIndex]); - useKeyboardShortcut(CONST.KEYBOARD_SHORTCUTS.ARROW_LEFT, arrowLeftCallback, arrowConfig); + useKeyboardShortcut(CONST.KEYBOARD_SHORTCUTS.ARROW_LEFT, arrowLeftCallback, horizontalArrowConfig); const arrowRightCallback = useCallback(() => { if (maxIndex < 0 || !allowHorizontalArrowKeys) { @@ -182,7 +193,7 @@ export default function useArrowKeyFocusManager({ }); }, [allowHorizontalArrowKeys, disableCyclicTraversal, disabledIndexes, maxIndex]); - useKeyboardShortcut(CONST.KEYBOARD_SHORTCUTS.ARROW_RIGHT, arrowRightCallback, arrowConfig); + useKeyboardShortcut(CONST.KEYBOARD_SHORTCUTS.ARROW_RIGHT, arrowRightCallback, horizontalArrowConfig); // Note: you don't need to manually manage focusedIndex in the parent. setFocusedIndex is only exposed in case you want to reset focusedIndex or focus a specific item return [focusedIndex, setFocusedIndex]; diff --git a/src/hooks/useMarkdownStyle.ts b/src/hooks/useMarkdownStyle.ts index 382cce2c8b09..e753218e8406 100644 --- a/src/hooks/useMarkdownStyle.ts +++ b/src/hooks/useMarkdownStyle.ts @@ -16,7 +16,7 @@ function useMarkdownStyle(): MarkdownStyle { color: theme.link, }, h1: { - fontSize: variables.fontSizeh1, + fontSize: variables.fontSizeLarge, }, blockquote: { borderColor: theme.border, diff --git a/src/hooks/useViolations.ts b/src/hooks/useViolations.ts index 76d48158237b..70e66ab65a08 100644 --- a/src/hooks/useViolations.ts +++ b/src/hooks/useViolations.ts @@ -49,14 +49,13 @@ type ViolationsMap = Map; function useViolations(violations: TransactionViolation[]) { const violationsByField = useMemo((): ViolationsMap => { + const filteredViolations = violations.filter((violation) => violation.type === 'violation'); const violationGroups = new Map(); - - for (const violation of violations) { + for (const violation of filteredViolations) { const field = violationFields[violation.name]; const existingViolations = violationGroups.get(field) ?? []; violationGroups.set(field, [...existingViolations, violation]); } - return violationGroups ?? new Map(); }, [violations]); diff --git a/src/languages/en.ts b/src/languages/en.ts index e940e8876980..472faa8d4f71 100755 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -84,7 +84,6 @@ import type { ViolationsInvoiceMarkupParams, ViolationsMaxAgeParams, ViolationsMissingTagParams, - ViolationsOverAutoApprovalLimitParams, ViolationsOverCategoryLimitParams, ViolationsOverLimitParams, ViolationsPerDayLimitParams, @@ -719,6 +718,10 @@ export default { subtitle: 'These details are used for travel and payments. They are never shown on your public profile.', }, }, + securityPage: { + title: 'Security options', + subtitle: 'Enable two-factor authentication to keep your account safe.', + }, shareCodePage: { title: 'Your code', subtitle: 'Invite members to Expensify by sharing your personal QR code or referral link.', @@ -1052,7 +1055,16 @@ export default { defaultPaymentMethod: 'Default', }, preferencesPage: { + appSection: { + title: 'App preferences', + subtitle: 'Customize your Expensify account.', + }, + testSection: { + title: 'Test preferences', + subtitle: 'Settings to help debug and test the app on staging.', + }, receiveRelevantFeatureUpdatesAndExpensifyNews: 'Receive relevant feature updates and Expensify news', + muteAllSounds: 'Mute all sounds from Expensify', }, priorityModePage: { priorityMode: 'Priority mode', @@ -1622,6 +1634,9 @@ export default { trackDistanceCopy: 'Set the per mile/km rate and choose a default unit to track.', trackDistanceRate: 'Rate', trackDistanceUnit: 'Unit', + trackDistanceChooseUnit: 'Choose a default unit to track.', + kilometers: 'Kilometers', + miles: 'Miles', unlockNextDayReimbursements: 'Unlock next-day reimbursements', captureNoVBACopyBeforeEmail: 'Ask your workspace members to forward receipts to ', captureNoVBACopyAfterEmail: ' and download the Expensify App to track cash expenses on the go.', @@ -2142,7 +2157,7 @@ export default { allTagLevelsRequired: 'All tags required', autoReportedRejectedExpense: ({rejectReason, rejectedBy}: ViolationsAutoReportedRejectedExpenseParams) => `${rejectedBy} rejected this expense with the comment "${rejectReason}"`, billableExpense: 'Billable no longer valid', - cashExpenseWithNoReceipt: ({amount}: ViolationsCashExpenseWithNoReceiptParams) => `Receipt required over ${amount}`, + cashExpenseWithNoReceipt: ({formattedLimit}: ViolationsCashExpenseWithNoReceiptParams) => `Receipt required${formattedLimit ? ` over ${formattedLimit}` : ''}`, categoryOutOfPolicy: 'Category no longer valid', conversionSurcharge: ({surcharge}: ViolationsConversionSurchargeParams) => `Applied ${surcharge}% conversion surcharge`, customUnitOutOfPolicy: 'Unit no longer valid', @@ -2153,17 +2168,18 @@ export default { maxAge: ({maxAge}: ViolationsMaxAgeParams) => `Date older than ${maxAge} days`, missingCategory: 'Missing category', missingComment: 'Description required for selected category', - missingTag: ({tagName}: ViolationsMissingTagParams) => `Missing ${tagName ?? 'tag'}`, + missingTag: ({tagName}: ViolationsMissingTagParams = {}) => `Missing ${tagName ?? 'tag'}`, modifiedAmount: 'Amount greater than scanned receipt', modifiedDate: 'Date differs from scanned receipt', nonExpensiworksExpense: 'Non-Expensiworks expense', - overAutoApprovalLimit: ({formattedLimitAmount}: ViolationsOverAutoApprovalLimitParams) => `Expense exceeds auto approval limit of ${formattedLimitAmount}`, - overCategoryLimit: ({categoryLimit}: ViolationsOverCategoryLimitParams) => `Amount over ${categoryLimit}/person category limit`, - overLimit: ({amount}: ViolationsOverLimitParams) => `Amount over ${amount}/person limit`, - overLimitAttendee: ({amount}: ViolationsOverLimitParams) => `Amount over ${amount}/person limit`, - perDayLimit: ({limit}: ViolationsPerDayLimitParams) => `Amount over daily ${limit}/person category limit`, + overAutoApprovalLimit: ({formattedLimit}: ViolationsOverLimitParams) => `Expense exceeds auto approval limit of ${formattedLimit}`, + overCategoryLimit: ({formattedLimit}: ViolationsOverCategoryLimitParams) => `Amount over ${formattedLimit}/person category limit`, + overLimit: ({formattedLimit}: ViolationsOverLimitParams) => `Amount over ${formattedLimit}/person limit`, + overLimitAttendee: ({formattedLimit}: ViolationsOverLimitParams) => `Amount over ${formattedLimit}/person limit`, + perDayLimit: ({formattedLimit}: ViolationsPerDayLimitParams) => `Amount over daily ${formattedLimit}/person category limit`, receiptNotSmartScanned: 'Receipt not verified. Please confirm accuracy.', - receiptRequired: ({amount, category}: ViolationsReceiptRequiredParams) => `Receipt required over ${amount} ${category ? ' category limit' : ''}`, + receiptRequired: ({formattedLimit, category}: ViolationsReceiptRequiredParams = {}) => + `Receipt required${formattedLimit ? ` over ${formattedLimit}${category ? ' category limit' : ''}` : ''}`, rter: ({brokenBankConnection, email, isAdmin, isTransactionOlderThan7Days, member}: ViolationsRterParams) => { if (brokenBankConnection) { return isAdmin diff --git a/src/languages/es.ts b/src/languages/es.ts index 3a0173d168c7..dfbe31668c5e 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -713,6 +713,10 @@ export default { subtitle: 'Estos detalles se utilizan para viajes y pagos. Nunca se mostrarán en tu perfil público.', }, }, + securityPage: { + title: 'Opciones de seguridad', + subtitle: 'Activa la autenticación de dos factores para mantener tu cuenta segura.', + }, shareCodePage: { title: 'Tu código', subtitle: 'Invita a miembros a Expensify compartiendo tu código QR personal o enlace de invitación.', @@ -1049,7 +1053,16 @@ export default { defaultPaymentMethod: 'Predeterminado', }, preferencesPage: { + appSection: { + title: 'Preferencias de la aplicación', + subtitle: 'Personaliza tu cuenta de Expensify.', + }, + testSection: { + title: 'Preferencias para tests', + subtitle: 'Ajustes para ayudar a depurar y probar la aplicación en “staging”.', + }, receiveRelevantFeatureUpdatesAndExpensifyNews: 'Recibir noticias sobre Expensify y actualizaciones del producto', + muteAllSounds: 'Silenciar todos los sonidos de Expensify', }, priorityModePage: { priorityMode: 'Modo prioridad', @@ -1645,6 +1658,9 @@ export default { trackDistanceCopy: 'Configura la tarifa y unidad usadas para medir distancias.', trackDistanceRate: 'Tarifa', trackDistanceUnit: 'Unidad', + trackDistanceChooseUnit: 'Elija una unidad predeterminada para rastrear.', + kilometers: 'Kilómetros', + miles: 'Millas', unlockNextDayReimbursements: 'Desbloquea reembolsos diarios', captureNoVBACopyBeforeEmail: 'Pide a los miembros de tu espacio de trabajo que envíen recibos a ', captureNoVBACopyAfterEmail: ' y descarga la App de Expensify para controlar tus gastos en efectivo sobre la marcha.', @@ -2629,9 +2645,9 @@ export default { allTagLevelsRequired: 'Todas las etiquetas son obligatorias', autoReportedRejectedExpense: ({rejectedBy, rejectReason}: ViolationsAutoReportedRejectedExpenseParams) => `${rejectedBy} rechazó la solicitud y comentó "${rejectReason}"`, billableExpense: 'La opción facturable ya no es válida', - cashExpenseWithNoReceipt: ({amount}: ViolationsCashExpenseWithNoReceiptParams) => `Recibo obligatorio para cantidades mayores de ${amount}`, + cashExpenseWithNoReceipt: ({formattedLimit}: ViolationsCashExpenseWithNoReceiptParams) => `Recibo obligatorio para cantidades mayores de ${formattedLimit}`, categoryOutOfPolicy: 'La categoría ya no es válida', - conversionSurcharge: ({surcharge}: ViolationsConversionSurchargeParams) => `${surcharge}% de recargo aplicado`, + conversionSurcharge: ({surcharge}: ViolationsConversionSurchargeParams = {}) => `${surcharge}% de recargo aplicado`, customUnitOutOfPolicy: 'La unidad ya no es válida', duplicatedTransaction: 'Posible duplicado', fieldRequired: 'Los campos del informe son obligatorios', @@ -2640,17 +2656,19 @@ export default { maxAge: ({maxAge}: ViolationsMaxAgeParams) => `Fecha de más de ${maxAge} días`, missingCategory: 'Falta categoría', missingComment: 'Descripción obligatoria para la categoría seleccionada', - missingTag: ({tagName}: ViolationsMissingTagParams) => `Falta ${tagName}`, + missingTag: ({tagName}: ViolationsMissingTagParams = {}) => `Falta ${tagName ?? 'etiqueta'}`, modifiedAmount: 'Importe superior al del recibo escaneado', modifiedDate: 'Fecha difiere del recibo escaneado', nonExpensiworksExpense: 'Gasto no proviene de Expensiworks', - overAutoApprovalLimit: ({formattedLimitAmount}: ViolationsOverAutoApprovalLimitParams) => `Importe supera el límite de aprobación automática de ${formattedLimitAmount}`, - overCategoryLimit: ({categoryLimit}: ViolationsOverCategoryLimitParams) => `Importe supera el límite para la categoría de ${categoryLimit}/persona`, - overLimit: ({amount}: ViolationsOverLimitParams) => `Importe supera el límite de ${amount}/persona`, - overLimitAttendee: ({amount}: ViolationsOverLimitParams) => `Importe supera el límite de ${amount}/persona`, - perDayLimit: ({limit}: ViolationsPerDayLimitParams) => `Importe supera el límite diario de la categoría de ${limit}/persona`, + overAutoApprovalLimit: ({formattedLimit}: ViolationsOverAutoApprovalLimitParams) => + `Importe supera el límite de aprobación automática${formattedLimit ? ` de ${formattedLimit}` : ''}`, + overCategoryLimit: ({formattedLimit}: ViolationsOverCategoryLimitParams) => `Importe supera el límite para la categoría${formattedLimit ? ` de ${formattedLimit}/persona` : ''}`, + overLimit: ({formattedLimit}: ViolationsOverLimitParams) => `Importe supera el límite${formattedLimit ? ` de ${formattedLimit}/persona` : ''}`, + overLimitAttendee: ({formattedLimit}: ViolationsOverLimitParams) => `Importe supera el límite${formattedLimit ? ` de ${formattedLimit}/persona` : ''}`, + perDayLimit: ({formattedLimit}: ViolationsPerDayLimitParams) => `Importe supera el límite diario de la categoría${formattedLimit ? ` de ${formattedLimit}/persona` : ''}`, receiptNotSmartScanned: 'Recibo no verificado. Por favor, confirma su exactitud', - receiptRequired: ({amount, category}: ViolationsReceiptRequiredParams) => `Recibo obligatorio para importes sobre ${category ? 'el limite de la categoría de ' : ''}${amount}`, + receiptRequired: ({formattedLimit, category}: ViolationsReceiptRequiredParams = {}) => + `Recibo obligatorio${formattedLimit ? ` para importes sobre ${category ? 'el limite de la categoría de ' : ''}${formattedLimit}` : ''}`, rter: ({brokenBankConnection, isAdmin, email, isTransactionOlderThan7Days, member}: ViolationsRterParams) => { if (brokenBankConnection) { return isAdmin diff --git a/src/languages/types.ts b/src/languages/types.ts index c9442c6560a3..ca98e23a4c07 100644 --- a/src/languages/types.ts +++ b/src/languages/types.ts @@ -213,7 +213,7 @@ type WalletProgramParams = {walletProgram: string}; type ViolationsAutoReportedRejectedExpenseParams = {rejectedBy: string; rejectReason: string}; -type ViolationsCashExpenseWithNoReceiptParams = {amount: string}; +type ViolationsCashExpenseWithNoReceiptParams = {formattedLimit?: string}; type ViolationsConversionSurchargeParams = {surcharge?: number}; @@ -223,15 +223,15 @@ type ViolationsMaxAgeParams = {maxAge: number}; type ViolationsMissingTagParams = {tagName?: string}; -type ViolationsOverAutoApprovalLimitParams = {formattedLimitAmount: string}; +type ViolationsOverAutoApprovalLimitParams = {formattedLimit?: string}; -type ViolationsOverCategoryLimitParams = {categoryLimit: string}; +type ViolationsOverCategoryLimitParams = {formattedLimit?: string}; -type ViolationsOverLimitParams = {amount: string}; +type ViolationsOverLimitParams = {formattedLimit?: string}; -type ViolationsPerDayLimitParams = {limit: string}; +type ViolationsPerDayLimitParams = {formattedLimit?: string}; -type ViolationsReceiptRequiredParams = {amount: string; category?: string}; +type ViolationsReceiptRequiredParams = {formattedLimit?: string; category?: string}; type ViolationsRterParams = { brokenBankConnection: boolean; diff --git a/src/libs/API/parameters/UpdateWorkspaceCustomUnitAndRateParams.ts b/src/libs/API/parameters/UpdateWorkspaceCustomUnitAndRateParams.ts index 22bbd20c7308..010bcaa1e60a 100644 --- a/src/libs/API/parameters/UpdateWorkspaceCustomUnitAndRateParams.ts +++ b/src/libs/API/parameters/UpdateWorkspaceCustomUnitAndRateParams.ts @@ -1,6 +1,6 @@ type UpdateWorkspaceCustomUnitAndRateParams = { policyID: string; - lastModified: number; + lastModified?: string; customUnit: string; customUnitRate: string; }; diff --git a/src/libs/CurrencyUtils.ts b/src/libs/CurrencyUtils.ts index cec9d1e09088..4098cbcd31fc 100644 --- a/src/libs/CurrencyUtils.ts +++ b/src/libs/CurrencyUtils.ts @@ -110,6 +110,21 @@ function convertToDisplayString(amountInCents = 0, currency: string = CONST.CURR }); } +/** + * Given an amount, convert it to a string for display in the UI. + * + * @param amount – should be a float. + * @param currency - IOU currency + */ +function convertAmountToDisplayString(amount = 0, currency: string = CONST.CURRENCY.USD): string { + const convertedAmount = amount / 100.0; + return NumberFormatUtils.format(BaseLocaleListener.getPreferredLocale(), convertedAmount, { + style: 'currency', + currency, + minimumFractionDigits: getCurrencyDecimals(currency) + 1, + }); +} + /** * Checks if passed currency code is a valid currency based on currency list */ @@ -127,5 +142,6 @@ export { convertToBackendAmount, convertToFrontendAmount, convertToDisplayString, + convertAmountToDisplayString, isValidCurrencyCode, }; diff --git a/src/libs/DoInteractionTask/index.desktop.ts b/src/libs/DoInteractionTask/index.desktop.ts index 73b3cb19ec32..3b0340d68425 100644 --- a/src/libs/DoInteractionTask/index.desktop.ts +++ b/src/libs/DoInteractionTask/index.desktop.ts @@ -1,10 +1,10 @@ import {InteractionManager} from 'react-native'; +import type DoInteractionTask from './types'; // For desktop, we should call the callback after all interactions to prevent freezing. See more detail in https://github.com/Expensify/App/issues/28916 -function doInteractionTask(callback: () => void) { - return InteractionManager.runAfterInteractions(() => { +const doInteractionTask: DoInteractionTask = (callback) => + InteractionManager.runAfterInteractions(() => { callback(); }); -} export default doInteractionTask; diff --git a/src/libs/DoInteractionTask/index.ts b/src/libs/DoInteractionTask/index.ts index dffbb0562b98..0eadb8f7dbee 100644 --- a/src/libs/DoInteractionTask/index.ts +++ b/src/libs/DoInteractionTask/index.ts @@ -1,6 +1,8 @@ -function doInteractionTask(callback: () => void) { +import type DoInteractionTask from './types'; + +const doInteractionTask: DoInteractionTask = (callback) => { callback(); return null; -} +}; export default doInteractionTask; diff --git a/src/libs/DoInteractionTask/types.ts b/src/libs/DoInteractionTask/types.ts new file mode 100644 index 000000000000..3c1da4b5364d --- /dev/null +++ b/src/libs/DoInteractionTask/types.ts @@ -0,0 +1,5 @@ +import type {InteractionManager} from 'react-native'; + +type DoInteractionTask = (callback: () => void) => ReturnType | null; + +export default DoInteractionTask; diff --git a/src/libs/ErrorUtils.ts b/src/libs/ErrorUtils.ts index 8cfaa684917e..58946983bb35 100644 --- a/src/libs/ErrorUtils.ts +++ b/src/libs/ErrorUtils.ts @@ -132,7 +132,7 @@ function getErrorsWithTranslationData(errors: Localize.MaybePhraseKey | Errors): * @param errors - An object containing current errors in the form * @param message - Message to assign to the inputID errors */ -function addErrorMessage(errors: Errors, inputID?: string, message?: TKey | Localize.MaybePhraseKey) { +function addErrorMessage(errors: Errors, inputID?: string | null, message?: TKey | Localize.MaybePhraseKey) { if (!message || !inputID) { return; } diff --git a/src/libs/MoneyRequestUtils.ts b/src/libs/MoneyRequestUtils.ts index ff86070395b3..da8a5b843ec1 100644 --- a/src/libs/MoneyRequestUtils.ts +++ b/src/libs/MoneyRequestUtils.ts @@ -34,10 +34,10 @@ function addLeadingZero(amount: string): string { /** * Calculate the length of the amount with leading zeroes */ -function calculateAmountLength(amount: string): number { +function calculateAmountLength(amount: string, decimals: number): number { const leadingZeroes = amount.match(/^0+/); const leadingZeroesLength = leadingZeroes?.[0]?.length ?? 0; - const absAmount = parseFloat((Number(stripCommaFromAmount(amount)) * 100).toFixed(2)).toString(); + const absAmount = parseFloat((Number(stripCommaFromAmount(amount)) * 10 ** decimals).toFixed(2)).toString(); if (/\D/.test(absAmount)) { return CONST.IOU.AMOUNT_MAX_LENGTH + 1; @@ -55,7 +55,7 @@ function validateAmount(amount: string, decimals: number): boolean { ? `^\\d+(,\\d*)*$` // Don't allow decimal point if decimals === 0 : `^\\d+(,\\d*)*(\\.\\d{0,${decimals}})?$`; // Allow the decimal point and the desired number of digits after the point const decimalNumberRegex = new RegExp(regexString, 'i'); - return amount === '' || (decimalNumberRegex.test(amount) && calculateAmountLength(amount) <= CONST.IOU.AMOUNT_MAX_LENGTH); + return amount === '' || (decimalNumberRegex.test(amount) && calculateAmountLength(amount, decimals) <= CONST.IOU.AMOUNT_MAX_LENGTH); } /** diff --git a/src/libs/Navigation/AppNavigator/ModalStackNavigators.tsx b/src/libs/Navigation/AppNavigator/ModalStackNavigators.tsx index b0ec6d1f3a94..4723bbfd9b4e 100644 --- a/src/libs/Navigation/AppNavigator/ModalStackNavigators.tsx +++ b/src/libs/Navigation/AppNavigator/ModalStackNavigators.tsx @@ -99,7 +99,6 @@ const MoneyRequestModalStackNavigator = createModalStackNavigator require('../../../pages/iou/steps/MoneyRequstParticipantsPage/MoneyRequestParticipantsPage').default as React.ComponentType, [SCREENS.MONEY_REQUEST.CONFIRMATION]: () => require('../../../pages/iou/steps/MoneyRequestConfirmPage').default as React.ComponentType, [SCREENS.MONEY_REQUEST.CURRENCY]: () => require('../../../pages/iou/IOUCurrencySelection').default as React.ComponentType, - [SCREENS.MONEY_REQUEST.DATE]: () => require('../../../pages/iou/MoneyRequestDatePage').default as React.ComponentType, [SCREENS.MONEY_REQUEST.CATEGORY]: () => require('../../../pages/iou/MoneyRequestCategoryPage').default as React.ComponentType, [SCREENS.MONEY_REQUEST.MERCHANT]: () => require('../../../pages/iou/MoneyRequestMerchantPage').default as React.ComponentType, [SCREENS.IOU_SEND.ADD_BANK_ACCOUNT]: () => require('../../../pages/AddPersonalBankAccountPage').default as React.ComponentType, @@ -238,7 +237,9 @@ const SettingsModalStackNavigator = createModalStackNavigator require('../../../pages/settings/Profile/CustomStatus/StatusClearAfterPage').default as React.ComponentType, [SCREENS.SETTINGS.PROFILE.STATUS_CLEAR_AFTER_DATE]: () => require('../../../pages/settings/Profile/CustomStatus/SetDatePage').default as React.ComponentType, [SCREENS.SETTINGS.PROFILE.STATUS_CLEAR_AFTER_TIME]: () => require('../../../pages/settings/Profile/CustomStatus/SetTimePage').default as React.ComponentType, - [SCREENS.WORKSPACE.RATE_AND_UNIT]: () => require('../../../pages/workspace/reimburse/WorkspaceRateAndUnitPage').default as React.ComponentType, + [SCREENS.WORKSPACE.RATE_AND_UNIT]: () => require('../../../pages/workspace/reimburse/WorkspaceRateAndUnitPage/InitialPage').default as React.ComponentType, + [SCREENS.WORKSPACE.RATE_AND_UNIT_RATE]: () => require('../../../pages/workspace/reimburse/WorkspaceRateAndUnitPage/RatePage').default as React.ComponentType, + [SCREENS.WORKSPACE.RATE_AND_UNIT_UNIT]: () => require('../../../pages/workspace/reimburse/WorkspaceRateAndUnitPage/UnitPage').default as React.ComponentType, [SCREENS.WORKSPACE.INVITE]: () => require('../../../pages/workspace/WorkspaceInvitePage').default as React.ComponentType, [SCREENS.WORKSPACE.INVITE_MESSAGE]: () => require('../../../pages/workspace/WorkspaceInviteMessagePage').default as React.ComponentType, [SCREENS.WORKSPACE.NAME]: () => require('../../../pages/workspace/WorkspaceNamePage').default as React.ComponentType, diff --git a/src/libs/Navigation/linkingConfig/CENTRAL_PANE_TO_RHP_MAPPING.ts b/src/libs/Navigation/linkingConfig/CENTRAL_PANE_TO_RHP_MAPPING.ts index d96ad416832d..1936de564c37 100755 --- a/src/libs/Navigation/linkingConfig/CENTRAL_PANE_TO_RHP_MAPPING.ts +++ b/src/libs/Navigation/linkingConfig/CENTRAL_PANE_TO_RHP_MAPPING.ts @@ -3,7 +3,7 @@ import SCREENS from '@src/SCREENS'; const CENTRAL_PANE_TO_RHP_MAPPING: Partial> = { [SCREENS.WORKSPACE.PROFILE]: [SCREENS.WORKSPACE.NAME, SCREENS.WORKSPACE.CURRENCY], - [SCREENS.WORKSPACE.REIMBURSE]: [SCREENS.WORKSPACE.RATE_AND_UNIT], + [SCREENS.WORKSPACE.REIMBURSE]: [SCREENS.WORKSPACE.RATE_AND_UNIT, SCREENS.WORKSPACE.RATE_AND_UNIT_RATE, SCREENS.WORKSPACE.RATE_AND_UNIT_UNIT], [SCREENS.WORKSPACE.MEMBERS]: [SCREENS.WORKSPACE.INVITE, SCREENS.WORKSPACE.INVITE_MESSAGE], }; diff --git a/src/libs/Navigation/linkingConfig/config.ts b/src/libs/Navigation/linkingConfig/config.ts index ab218adc3879..52e512d32c59 100644 --- a/src/libs/Navigation/linkingConfig/config.ts +++ b/src/libs/Navigation/linkingConfig/config.ts @@ -229,6 +229,12 @@ const config: LinkingOptions['config'] = { [SCREENS.WORKSPACE.RATE_AND_UNIT]: { path: ROUTES.WORKSPACE_RATE_AND_UNIT.route, }, + [SCREENS.WORKSPACE.RATE_AND_UNIT_RATE]: { + path: ROUTES.WORKSPACE_RATE_AND_UNIT_RATE.route, + }, + [SCREENS.WORKSPACE.RATE_AND_UNIT_UNIT]: { + path: ROUTES.WORKSPACE_RATE_AND_UNIT_UNIT.route, + }, [SCREENS.WORKSPACE.INVITE]: { path: ROUTES.WORKSPACE_INVITE.route, }, @@ -405,7 +411,6 @@ const config: LinkingOptions['config'] = { [SCREENS.MONEY_REQUEST.STEP_TAX_RATE]: ROUTES.MONEY_REQUEST_STEP_TAX_RATE.route, [SCREENS.MONEY_REQUEST.PARTICIPANTS]: ROUTES.MONEY_REQUEST_PARTICIPANTS.route, [SCREENS.MONEY_REQUEST.CONFIRMATION]: ROUTES.MONEY_REQUEST_CONFIRMATION.route, - [SCREENS.MONEY_REQUEST.DATE]: ROUTES.MONEY_REQUEST_DATE.route, [SCREENS.MONEY_REQUEST.CURRENCY]: ROUTES.MONEY_REQUEST_CURRENCY.route, [SCREENS.MONEY_REQUEST.CATEGORY]: ROUTES.MONEY_REQUEST_CATEGORY.route, [SCREENS.MONEY_REQUEST.MERCHANT]: ROUTES.MONEY_REQUEST_MERCHANT.route, diff --git a/src/libs/Navigation/types.ts b/src/libs/Navigation/types.ts index bbdb03ab3df8..0bc574b12b15 100644 --- a/src/libs/Navigation/types.ts +++ b/src/libs/Navigation/types.ts @@ -140,7 +140,15 @@ type SettingsNavigatorParamList = { [SCREENS.SETTINGS.PROFILE.STATUS_CLEAR_AFTER_TIME]: undefined; [SCREENS.WORKSPACE.CURRENCY]: undefined; [SCREENS.WORKSPACE.NAME]: undefined; - [SCREENS.WORKSPACE.RATE_AND_UNIT]: undefined; + [SCREENS.WORKSPACE.RATE_AND_UNIT]: { + policyID: string; + }; + [SCREENS.WORKSPACE.RATE_AND_UNIT_RATE]: { + policyID: string; + }; + [SCREENS.WORKSPACE.RATE_AND_UNIT_UNIT]: { + policyID: string; + }; [SCREENS.WORKSPACE.INVITE]: { policyID: string; }; @@ -224,11 +232,12 @@ type MoneyRequestNavigatorParamList = { currency: string; backTo: string; }; - [SCREENS.MONEY_REQUEST.DATE]: { - iouType: string; + [SCREENS.MONEY_REQUEST.STEP_DATE]: { + action: ValueOf; + iouType: ValueOf; + transactionID: string; reportID: string; - field: string; - threadReportID: string; + backTo: string; }; [SCREENS.MONEY_REQUEST.STEP_DESCRIPTION]: { action: ValueOf; @@ -338,7 +347,10 @@ type ReimbursementAccountNavigatorParamList = { }; type WalletStatementNavigatorParamList = { - [SCREENS.WALLET_STATEMENT_ROOT]: undefined; + [SCREENS.WALLET_STATEMENT_ROOT]: { + /** The statement year and month as one string, i.e. 202110 */ + yearMonth: string; + }; }; type FlagCommentNavigatorParamList = { diff --git a/src/libs/Notification/LocalNotification/BrowserNotifications.ts b/src/libs/Notification/LocalNotification/BrowserNotifications.ts index 911e665f3ff4..f44b6802b540 100644 --- a/src/libs/Notification/LocalNotification/BrowserNotifications.ts +++ b/src/libs/Notification/LocalNotification/BrowserNotifications.ts @@ -42,7 +42,7 @@ function canUseBrowserNotifications(): Promise { * @param icon Path to icon * @param data extra data to attach to the notification */ -function push(title: string, body = '', icon: string | ImageSourcePropType = '', data: LocalNotificationData = {}, onClick: LocalNotificationClickHandler = () => {}) { +function push(title: string, body = '', icon: string | ImageSourcePropType = '', data: LocalNotificationData = {}, onClick: LocalNotificationClickHandler = () => {}, silent = false) { canUseBrowserNotifications().then((canUseNotifications) => { if (!canUseNotifications) { return; @@ -54,6 +54,7 @@ function push(title: string, body = '', icon: string | ImageSourcePropType = '', body, icon: String(icon), data, + silent, }); notificationCache[notificationID].onclick = () => { onClick(); @@ -104,7 +105,7 @@ export default { reportID: report.reportID, }; - push(title, body, icon, data, onClick); + push(title, body, icon, data, onClick, true); }, pushModifiedExpenseNotification(report: Report, reportAction: ReportAction, onClick: LocalNotificationClickHandler, usesIcon = false) { diff --git a/src/libs/OptionsListUtils.ts b/src/libs/OptionsListUtils.ts index 046a66a8d6e0..0515260378e0 100644 --- a/src/libs/OptionsListUtils.ts +++ b/src/libs/OptionsListUtils.ts @@ -1756,7 +1756,7 @@ function getIOUConfirmationOptionsFromParticipants(participants: Participant[], * Build the options for the New Group view */ function getFilteredOptions( - reports: Record, + reports: OnyxCollection, personalDetails: OnyxEntry, betas: Beta[] = [], searchValue = '', @@ -1942,7 +1942,7 @@ function formatSectionsFromSearchTerm( searchTerm: string, selectedOptions: ReportUtils.OptionData[], filteredRecentReports: ReportUtils.OptionData[], - filteredPersonalDetails: PersonalDetails[], + filteredPersonalDetails: ReportUtils.OptionData[], maxOptionsSelected: boolean, indexOffset = 0, personalDetails: OnyxEntry = {}, @@ -2025,4 +2025,4 @@ export { transformedTaxRates, }; -export type {MemberForList}; +export type {MemberForList, CategorySection}; diff --git a/src/libs/PolicyUtils.ts b/src/libs/PolicyUtils.ts index 0575e297da0c..a3729ef81fe4 100644 --- a/src/libs/PolicyUtils.ts +++ b/src/libs/PolicyUtils.ts @@ -15,7 +15,9 @@ type UnitRate = {rate: number}; * These are policies that we can use to create reports with in NewDot. */ function getActivePolicies(policies: OnyxCollection): Policy[] | undefined { - return Object.values(policies ?? {}).filter((policy): policy is Policy => policy !== null && policy && policy.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE); + return Object.values(policies ?? {}).filter( + (policy): policy is Policy => policy !== null && policy && policy.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE && !!policy.name && !!policy.id, + ); } /** diff --git a/src/libs/ReportActionsUtils.ts b/src/libs/ReportActionsUtils.ts index 1aeb6e6e7343..3658fb53a722 100644 --- a/src/libs/ReportActionsUtils.ts +++ b/src/libs/ReportActionsUtils.ts @@ -73,6 +73,19 @@ Onyx.connect({ callback: (val) => (isNetworkOffline = val?.isOffline ?? false), }); +let currentUserAccountID: number | undefined; +Onyx.connect({ + key: ONYXKEYS.SESSION, + callback: (value) => { + // When signed out, value is undefined + if (!value) { + return; + } + + currentUserAccountID = value.accountID; + }, +}); + let environmentURL: string; Environment.getEnvironmentURL().then((url: string) => (environmentURL = url)); @@ -117,6 +130,16 @@ function isWhisperAction(reportAction: OnyxEntry): boolean { return (reportAction?.whisperedToAccountIDs ?? []).length > 0; } +/** + * Checks whether the report action is a whisper targeting someone other than the current user. + */ +function isWhisperActionTargetedToOthers(reportAction: OnyxEntry): boolean { + if (!isWhisperAction(reportAction)) { + return false; + } + return !reportAction?.whisperedToAccountIDs?.includes(currentUserAccountID ?? 0); +} + function isReimbursementQueuedAction(reportAction: OnyxEntry) { return reportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.REIMBURSEMENTQUEUED; } @@ -370,6 +393,10 @@ function shouldReportActionBeVisible(reportAction: OnyxEntry, key: return false; } + if (isWhisperActionTargetedToOthers(reportAction)) { + return false; + } + if (isPendingRemove(reportAction) && !reportAction.childVisibleActionCount) { return false; } @@ -864,6 +891,7 @@ export { isThreadParentMessage, isTransactionThread, isWhisperAction, + isWhisperActionTargetedToOthers, isReimbursementQueuedAction, shouldReportActionBeVisible, shouldHideNewMarker, diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index 26280f95447d..a2401ad926d3 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -3634,7 +3634,10 @@ function isUnread(report: OnyxEntry): boolean { // lastVisibleActionCreated and lastReadTime are both datetime strings and can be compared directly const lastVisibleActionCreated = report.lastVisibleActionCreated ?? ''; const lastReadTime = report.lastReadTime ?? ''; - return lastReadTime < lastVisibleActionCreated; + const lastMentionedTime = report.lastMentionedTime ?? ''; + + // If the user was mentioned and the comment got deleted the lastMentionedTime will be more recent than the lastVisibleActionCreated + return lastReadTime < lastVisibleActionCreated || lastReadTime < lastMentionedTime; } function isIOUOwnedByCurrentUser(report: OnyxEntry, allReportsDict: OnyxCollection = null): boolean { diff --git a/src/libs/Sound/config/index.native.ts b/src/libs/Sound/config/index.native.ts new file mode 100644 index 000000000000..e948c1e189e8 --- /dev/null +++ b/src/libs/Sound/config/index.native.ts @@ -0,0 +1,3 @@ +const config = {prefix: ''}; + +export default config; diff --git a/src/libs/Sound/config/index.ts b/src/libs/Sound/config/index.ts new file mode 100644 index 000000000000..f58755750d9d --- /dev/null +++ b/src/libs/Sound/config/index.ts @@ -0,0 +1,3 @@ +const config = {prefix: '/sounds/'}; + +export default config; diff --git a/src/libs/Sound/index.ts b/src/libs/Sound/index.ts new file mode 100644 index 000000000000..4639887e831c --- /dev/null +++ b/src/libs/Sound/index.ts @@ -0,0 +1,71 @@ +import Onyx from 'react-native-onyx'; +import Sound from 'react-native-sound'; +import type {ValueOf} from 'type-fest'; +import ONYXKEYS from '@src/ONYXKEYS'; +import config from './config'; + +let isMuted = false; + +Onyx.connect({ + key: ONYXKEYS.USER, + callback: (val) => (isMuted = !!val?.isMutedAllSounds), +}); + +const SOUNDS = { + DONE: 'done', + SUCCESS: 'success', + ATTENTION: 'attention', + RECEIVE: 'receive', +} as const; + +/** + * Creates a version of the given function that, when called, queues the execution and ensures that + * calls are spaced out by at least the specified `minExecutionTime`, even if called more frequently. This allows + * for throttling frequent calls to a function, ensuring each is executed with a minimum `minExecutionTime` between calls. + * Each call returns a promise that resolves when the function call is executed, allowing for asynchronous handling. + */ +function withMinimalExecutionTime) => ReturnType>(func: F, minExecutionTime: number) { + const queue: Array<[() => ReturnType, (value?: unknown) => void]> = []; + let timerId: NodeJS.Timeout | null = null; + + function processQueue() { + if (queue.length > 0) { + const next = queue.shift(); + + if (!next) { + return; + } + + const [nextFunc, resolve] = next; + nextFunc(); + resolve(); + timerId = setTimeout(processQueue, minExecutionTime); + } else { + timerId = null; + } + } + + return function (...args: Parameters) { + return new Promise((resolve) => { + queue.push([() => func(...args), resolve]); + + if (!timerId) { + // If the timer isn't running, start processing the queue + processQueue(); + } + }); + }; +} + +const playSound = (soundFile: ValueOf) => { + const sound = new Sound(`${config.prefix}${soundFile}.mp3`, Sound.MAIN_BUNDLE, (error) => { + if (error || isMuted) { + return; + } + + sound.play(); + }); +}; + +export {SOUNDS}; +export default withMinimalExecutionTime(playSound, 300); diff --git a/src/libs/Violations/ViolationsUtils.ts b/src/libs/Violations/ViolationsUtils.ts index 992449267934..0e14c1530259 100644 --- a/src/libs/Violations/ViolationsUtils.ts +++ b/src/libs/Violations/ViolationsUtils.ts @@ -93,22 +93,41 @@ const ViolationsUtils = { violation: TransactionViolation, translate: (phraseKey: TKey, ...phraseParameters: PhraseParameters>) => string, ): string { + const { + brokenBankConnection = false, + isAdmin = false, + email, + isTransactionOlderThan7Days = false, + member, + category, + rejectedBy, + rejectReason, + formattedLimit, + surcharge, + invoiceMarkup, + maxAge = 0, + tagName, + taxName, + } = violation.data ?? {}; + switch (violation.name) { case 'allTagLevelsRequired': return translate('violations.allTagLevelsRequired'); case 'autoReportedRejectedExpense': - return translate('violations.autoReportedRejectedExpense', { - rejectedBy: violation.data?.rejectedBy ?? '', - rejectReason: violation.data?.rejectReason ?? '', - }); + return rejectReason && rejectedBy + ? translate('violations.autoReportedRejectedExpense', { + rejectedBy, + rejectReason, + }) + : ''; case 'billableExpense': return translate('violations.billableExpense'); case 'cashExpenseWithNoReceipt': - return translate('violations.cashExpenseWithNoReceipt', {amount: violation.data?.amount ?? ''}); + return translate('violations.cashExpenseWithNoReceipt', {formattedLimit}); case 'categoryOutOfPolicy': return translate('violations.categoryOutOfPolicy'); case 'conversionSurcharge': - return translate('violations.conversionSurcharge', {surcharge: violation.data?.surcharge}); + return translate('violations.conversionSurcharge', {surcharge}); case 'customUnitOutOfPolicy': return translate('violations.customUnitOutOfPolicy'); case 'duplicatedTransaction': @@ -118,15 +137,15 @@ const ViolationsUtils = { case 'futureDate': return translate('violations.futureDate'); case 'invoiceMarkup': - return translate('violations.invoiceMarkup', {invoiceMarkup: violation.data?.invoiceMarkup}); + return translate('violations.invoiceMarkup', {invoiceMarkup}); case 'maxAge': - return translate('violations.maxAge', {maxAge: violation.data?.maxAge ?? 0}); + return translate('violations.maxAge', {maxAge}); case 'missingCategory': return translate('violations.missingCategory'); case 'missingComment': return translate('violations.missingComment'); case 'missingTag': - return translate('violations.missingTag', {tagName: violation.data?.tagName}); + return translate('violations.missingTag', {tagName}); case 'modifiedAmount': return translate('violations.modifiedAmount'); case 'modifiedDate': @@ -134,40 +153,37 @@ const ViolationsUtils = { case 'nonExpensiworksExpense': return translate('violations.nonExpensiworksExpense'); case 'overAutoApprovalLimit': - return translate('violations.overAutoApprovalLimit', {formattedLimitAmount: violation.data?.formattedLimitAmount ?? ''}); + return translate('violations.overAutoApprovalLimit', {formattedLimit}); case 'overCategoryLimit': - return translate('violations.overCategoryLimit', {categoryLimit: violation.data?.categoryLimit ?? ''}); + return translate('violations.overCategoryLimit', {formattedLimit}); case 'overLimit': - return translate('violations.overLimit', {amount: violation.data?.amount ?? ''}); + return translate('violations.overLimit', {formattedLimit}); case 'overLimitAttendee': - return translate('violations.overLimitAttendee', {amount: violation.data?.amount ?? ''}); + return translate('violations.overLimitAttendee', {formattedLimit}); case 'perDayLimit': - return translate('violations.perDayLimit', {limit: violation.data?.limit ?? ''}); + return translate('violations.perDayLimit', {formattedLimit}); case 'receiptNotSmartScanned': return translate('violations.receiptNotSmartScanned'); case 'receiptRequired': - return translate('violations.receiptRequired', { - amount: violation.data?.amount ?? '0', - category: violation.data?.category ?? '', - }); + return translate('violations.receiptRequired', {formattedLimit, category}); case 'rter': return translate('violations.rter', { - brokenBankConnection: violation.data?.brokenBankConnection ?? false, - isAdmin: violation.data?.isAdmin ?? false, - email: violation.data?.email, - isTransactionOlderThan7Days: !!violation.data?.isTransactionOlderThan7Days, - member: violation.data?.member, + brokenBankConnection, + isAdmin, + email, + isTransactionOlderThan7Days, + member, }); case 'smartscanFailed': return translate('violations.smartscanFailed'); case 'someTagLevelsRequired': return translate('violations.someTagLevelsRequired'); case 'tagOutOfPolicy': - return translate('violations.tagOutOfPolicy', {tagName: violation.data?.tagName}); + return translate('violations.tagOutOfPolicy', {tagName}); case 'taxAmountChanged': return translate('violations.taxAmountChanged'); case 'taxOutOfPolicy': - return translate('violations.taxOutOfPolicy', {taxName: violation.data?.taxName}); + return translate('violations.taxOutOfPolicy', {taxName}); case 'taxRateChanged': return translate('violations.taxRateChanged'); case 'taxRequired': diff --git a/src/libs/actions/IOU.ts b/src/libs/actions/IOU.ts index d7ac6f3c9361..7fca6614f1a1 100644 --- a/src/libs/actions/IOU.ts +++ b/src/libs/actions/IOU.ts @@ -269,8 +269,8 @@ function setMoneyRequestAmount_temporaryForRefactor(transactionID: string, amoun } // eslint-disable-next-line @typescript-eslint/naming-convention -function setMoneyRequestCreated_temporaryForRefactor(transactionID: string, created: string) { - Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transactionID}`, {created}); +function setMoneyRequestCreated(transactionID: string, created: string, isDraft: boolean) { + Onyx.merge(`${isDraft ? ONYXKEYS.COLLECTION.TRANSACTION_DRAFT : ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, {created}); } // eslint-disable-next-line @typescript-eslint/naming-convention @@ -1128,7 +1128,7 @@ function getUpdateMoneyRequestParams( }); } - // Optimistically modify the transaction + // Optimistically modify the transaction and the transaction thread optimisticData.push({ onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, @@ -1140,6 +1140,14 @@ function getUpdateMoneyRequestParams( }, }); + optimisticData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${transactionThreadReportID}`, + value: { + lastActorAccountID: updatedReportAction.actorAccountID, + }, + }); + if (isScanning && ('amount' in transactionChanges || 'currency' in transactionChanges)) { optimisticData.push( { @@ -1237,6 +1245,13 @@ function getUpdateMoneyRequestParams( }); } + // Reset the transaction thread to its original state + failureData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${transactionThreadReportID}`, + value: transactionThread, + }); + return { params, onyxData: {optimisticData, successData, failureData}, @@ -3712,10 +3727,6 @@ function setMoneyRequestAmount(amount: number) { Onyx.merge(ONYXKEYS.IOU, {amount}); } -function setMoneyRequestCreated(created: string) { - Onyx.merge(ONYXKEYS.IOU, {created}); -} - function setMoneyRequestCurrency(currency: string) { Onyx.merge(ONYXKEYS.IOU, {currency}); } @@ -3858,7 +3869,7 @@ export { setMoneyRequestAmount_temporaryForRefactor, setMoneyRequestBillable_temporaryForRefactor, setMoneyRequestCategory_temporaryForRefactor, - setMoneyRequestCreated_temporaryForRefactor, + setMoneyRequestCreated, setMoneyRequestCurrency_temporaryForRefactor, setMoneyRequestDescription, setMoneyRequestOriginalCurrency_temporaryForRefactor, @@ -3869,7 +3880,6 @@ export { setMoneyRequestAmount, setMoneyRequestBillable, setMoneyRequestCategory, - setMoneyRequestCreated, setMoneyRequestCurrency, setMoneyRequestId, setMoneyRequestMerchant, diff --git a/src/libs/actions/Policy.ts b/src/libs/actions/Policy.ts index 498ce6918509..f197a75871ef 100644 --- a/src/libs/actions/Policy.ts +++ b/src/libs/actions/Policy.ts @@ -49,7 +49,7 @@ import type { Transaction, } from '@src/types/onyx'; import type {Errors} from '@src/types/onyx/OnyxCommon'; -import type {CustomUnit} from '@src/types/onyx/Policy'; +import type {Attributes, CustomUnit, Rate, Unit} from '@src/types/onyx/Policy'; import type {EmptyObject} from '@src/types/utils/EmptyObject'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; @@ -82,6 +82,13 @@ type OptimisticCustomUnits = { type PoliciesRecord = Record>; +type NewCustomUnit = { + customUnitID: string; + name: string; + attributes: Attributes; + rates: Rate; +}; + const allPolicies: OnyxCollection = {}; Onyx.connect({ key: ONYXKEYS.COLLECTION.POLICY, @@ -116,6 +123,13 @@ Onyx.connect({ }, }); +let allReports: OnyxCollection = null; +Onyx.connect({ + key: ONYXKEYS.COLLECTION.REPORT, + waitForCollectionCallback: true, + callback: (value) => (allReports = value), +}); + let allPolicyMembers: OnyxCollection; Onyx.connect({ key: ONYXKEYS.COLLECTION.POLICY_MEMBERS, @@ -218,7 +232,7 @@ function hasActiveFreePolicy(policies: Array> | PoliciesRecord /** * Delete the workspace */ -function deleteWorkspace(policyID: string, reports: Report[], policyName: string) { +function deleteWorkspace(policyID: string, policyName: string) { if (!allPolicies) { return; } @@ -247,7 +261,9 @@ function deleteWorkspace(policyID: string, reports: Report[], policyName: string : []), ]; - reports.forEach(({reportID, ownerAccountID}) => { + const reportsToArchive = Object.values(allReports ?? {}).filter((report) => report?.policyID === policyID && (ReportUtils.isChatRoom(report) || ReportUtils.isPolicyExpenseChat(report))); + reportsToArchive.forEach((report) => { + const {reportID, ownerAccountID} = report ?? {}; optimisticData.push({ onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.REPORT}${reportID}`, @@ -292,7 +308,8 @@ function deleteWorkspace(policyID: string, reports: Report[], policyName: string }, ]; - reports.forEach(({reportID, stateNum, statusNum, hasDraft, oldPolicyName}) => { + reportsToArchive.forEach((report) => { + const {reportID, stateNum, statusNum, hasDraft, oldPolicyName} = report ?? {}; failureData.push({ onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.REPORT}${reportID}`, @@ -945,7 +962,7 @@ function hideWorkspaceAlertMessage(policyID: string) { Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, {alertMessage: ''}); } -function updateWorkspaceCustomUnitAndRate(policyID: string, currentCustomUnit: CustomUnit, newCustomUnit: CustomUnit, lastModified: number) { +function updateWorkspaceCustomUnitAndRate(policyID: string, currentCustomUnit: CustomUnit, newCustomUnit: NewCustomUnit, lastModified?: string) { if (!currentCustomUnit.customUnitID || !newCustomUnit?.customUnitID || !newCustomUnit.rates?.customUnitRateID) { return; } @@ -959,7 +976,7 @@ function updateWorkspaceCustomUnitAndRate(policyID: string, currentCustomUnit: C [newCustomUnit.customUnitID]: { ...newCustomUnit, rates: { - [newCustomUnit.rates.customUnitRateID as string]: { + [newCustomUnit.rates.customUnitRateID]: { ...newCustomUnit.rates, errors: null, pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, @@ -982,7 +999,7 @@ function updateWorkspaceCustomUnitAndRate(policyID: string, currentCustomUnit: C pendingAction: null, errors: null, rates: { - [newCustomUnit.rates.customUnitRateID as string]: { + [newCustomUnit.rates.customUnitRateID]: { pendingAction: null, }, }, @@ -1001,7 +1018,7 @@ function updateWorkspaceCustomUnitAndRate(policyID: string, currentCustomUnit: C [currentCustomUnit.customUnitID]: { customUnitID: currentCustomUnit.customUnitID, rates: { - [newCustomUnit.rates.customUnitRateID as string]: { + [newCustomUnit.rates.customUnitRateID]: { ...currentCustomUnit.rates, errors: ErrorUtils.getMicroSecondOnyxError('workspace.reimburse.updateCustomUnitError'), }, @@ -1460,6 +1477,22 @@ function openWorkspaceReimburseView(policyID: string) { API.read(READ_COMMANDS.OPEN_WORKSPACE_REIMBURSE_VIEW, params, {successData, failureData}); } +function setPolicyIDForReimburseView(policyID: string) { + Onyx.merge(ONYXKEYS.WORKSPACE_RATE_AND_UNIT, {policyID, rate: null, unit: null}); +} + +function clearOnyxDataForReimburseView() { + Onyx.merge(ONYXKEYS.WORKSPACE_RATE_AND_UNIT, null); +} + +function setRateForReimburseView(rate: string) { + Onyx.merge(ONYXKEYS.WORKSPACE_RATE_AND_UNIT, {rate}); +} + +function setUnitForReimburseView(unit: Unit) { + Onyx.merge(ONYXKEYS.WORKSPACE_RATE_AND_UNIT, {unit}); +} + /** * Returns the accountIDs of the members of the policy whose data is passed in the parameters */ @@ -2009,6 +2042,10 @@ export { clearAddMemberError, clearDeleteWorkspaceError, openWorkspaceReimburseView, + setPolicyIDForReimburseView, + clearOnyxDataForReimburseView, + setRateForReimburseView, + setUnitForReimburseView, generateDefaultWorkspaceName, updateGeneralSettings, clearWorkspaceGeneralSettingsErrors, diff --git a/src/libs/actions/Report.ts b/src/libs/actions/Report.ts index 3fbe9cf86e15..f9cf62c536c4 100644 --- a/src/libs/actions/Report.ts +++ b/src/libs/actions/Report.ts @@ -2566,7 +2566,7 @@ const updatePrivateNotes = (reportID: string, accountID: number, note: string) = }; /** Fetches all the private notes for a given report */ -function getReportPrivateNote(reportID: string) { +function getReportPrivateNote(reportID: string | undefined) { if (Session.isAnonymousUser()) { return; } diff --git a/src/libs/actions/Task.ts b/src/libs/actions/Task.ts index 901e3698376b..28cecf460a5f 100644 --- a/src/libs/actions/Task.ts +++ b/src/libs/actions/Task.ts @@ -13,6 +13,7 @@ import * as OptionsListUtils from '@libs/OptionsListUtils'; import * as PersonalDetailsUtils from '@libs/PersonalDetailsUtils'; import * as ReportActionsUtils from '@libs/ReportActionsUtils'; import * as ReportUtils from '@libs/ReportUtils'; +import playSound, {SOUNDS} from '@libs/Sound'; import * as UserUtils from '@libs/UserUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -309,6 +310,7 @@ function completeTask(taskReport: OnyxEntry) { completedTaskReportActionID: completedTaskReportAction.reportActionID, }; + playSound(SOUNDS.SUCCESS); API.write(WRITE_COMMANDS.COMPLETE_TASK, parameters, {optimisticData, successData, failureData}); } diff --git a/src/libs/actions/User.ts b/src/libs/actions/User.ts index 09cc6e49e6cc..75c61d7fab5f 100644 --- a/src/libs/actions/User.ts +++ b/src/libs/actions/User.ts @@ -28,6 +28,7 @@ import * as PersonalDetailsUtils from '@libs/PersonalDetailsUtils'; import * as Pusher from '@libs/Pusher/pusher'; import PusherUtils from '@libs/PusherUtils'; import * as ReportActionsUtils from '@libs/ReportActionsUtils'; +import playSound, {SOUNDS} from '@libs/Sound'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; @@ -37,6 +38,7 @@ import type {OnyxServerUpdate} from '@src/types/onyx/OnyxUpdatesFromServer'; import type OnyxPersonalDetails from '@src/types/onyx/PersonalDetails'; import type {Status} from '@src/types/onyx/PersonalDetails'; import type ReportAction from '@src/types/onyx/ReportAction'; +import type {OriginalMessage} from '@src/types/onyx/ReportAction'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; import type {EmptyObject} from '@src/types/utils/EmptyObject'; import * as Link from './Link'; @@ -458,7 +460,7 @@ function isBlockedFromConcierge(blockedFromConciergeNVP: OnyxEntry { - if (!update.shouldNotify) { + if (!update.shouldNotify && !update.shouldShowPushNotification) { return; } @@ -469,6 +471,101 @@ function triggerNotifications(onyxUpdates: OnyxServerUpdate[]) { }); } +const isChannelMuted = (reportId: string) => + new Promise((resolve) => { + const connectionId = Onyx.connect({ + key: `${ONYXKEYS.COLLECTION.REPORT}${reportId}`, + callback: (report) => { + Onyx.disconnect(connectionId); + + resolve( + !report?.notificationPreference || + report?.notificationPreference === CONST.REPORT.NOTIFICATION_PREFERENCE.MUTE || + report?.notificationPreference === CONST.REPORT.NOTIFICATION_PREFERENCE.HIDDEN, + ); + }, + }); + }); + +function playSoundForMessageType(pushJSON: OnyxServerUpdate[]) { + const reportActionsOnly = pushJSON.filter((update) => update.key.includes('reportActions_')); + // "reportActions_5134363522480668" -> "5134363522480668" + const reportIDs = reportActionsOnly.map((value) => value.key.split('_')[1]); + + Promise.all(reportIDs.map((reportID) => isChannelMuted(reportID))) + .then((muted) => muted.every((isMuted) => isMuted)) + .then((isSoundMuted) => { + if (isSoundMuted) { + return; + } + + try { + const flatten = reportActionsOnly.flatMap((update) => { + const value = update.value as OnyxCollection; + + if (!value) { + return []; + } + + return Object.values(value); + }) as ReportAction[]; + + for (const data of flatten) { + // Someone completes a task + if (data.actionName === 'TASKCOMPLETED') { + return playSound(SOUNDS.SUCCESS); + } + } + + const types = flatten.map((data) => data?.originalMessage).filter(Boolean) as OriginalMessage[]; + + for (const message of types) { + // someone sent money + if ('IOUDetails' in message) { + return playSound(SOUNDS.SUCCESS); + } + + // mention user + if ('html' in message && typeof message.html === 'string' && message.html.includes('')) { + return playSound(SOUNDS.ATTENTION); + } + + // mention @here + if ('html' in message && typeof message.html === 'string' && message.html.includes('')) { + return playSound(SOUNDS.ATTENTION); + } + + // assign a task + if ('taskReportID' in message) { + return playSound(SOUNDS.ATTENTION); + } + + // request money + if ('IOUTransactionID' in message) { + return playSound(SOUNDS.ATTENTION); + } + + // Someone completes a money request + if ('IOUReportID' in message) { + return playSound(SOUNDS.SUCCESS); + } + + // plain message + if ('html' in message) { + return playSound(SOUNDS.RECEIVE); + } + } + } catch (e) { + let errorMessage = String(e); + if (e instanceof Error) { + errorMessage = e.message; + } + + Log.client(`Unexpected error occurred while parsing the data to play a sound: ${errorMessage}`); + } + }); +} + /** * Handles the newest events from Pusher where a single mega multipleEvents contains * an array of singular events all in one event @@ -514,8 +611,10 @@ function subscribeToUserEvents() { }); // Handles Onyx updates coming from Pusher through the mega multipleEvents. - PusherUtils.subscribeToMultiEvent(Pusher.TYPE.MULTIPLE_EVENT_TYPE.ONYX_API_UPDATE, (pushJSON: OnyxServerUpdate[]) => - SequentialQueue.getCurrentRequest().then(() => { + PusherUtils.subscribeToMultiEvent(Pusher.TYPE.MULTIPLE_EVENT_TYPE.ONYX_API_UPDATE, (pushJSON: OnyxServerUpdate[]) => { + playSoundForMessageType(pushJSON); + + return SequentialQueue.getCurrentRequest().then(() => { // If we don't have the currentUserAccountID (user is logged out) we don't want to update Onyx with data from Pusher if (currentUserAccountID === -1) { return; @@ -528,8 +627,8 @@ function subscribeToUserEvents() { // Return a promise when Onyx is done updating so that the OnyxUpdatesManager can properly apply all // the onyx updates in order return onyxUpdatePromise; - }), - ); + }); + }); } /** @@ -613,6 +712,10 @@ function clearUserErrorMessage() { Onyx.merge(ONYXKEYS.USER, {error: ''}); } +function setMuteAllSounds(isMutedAllSounds: boolean) { + Onyx.merge(ONYXKEYS.USER, {isMutedAllSounds}); +} + /** * Clear the data about a screen share request from Onyx. */ @@ -880,6 +983,7 @@ export { subscribeToUserEvents, updatePreferredSkinTone, setShouldUseStagingServer, + setMuteAllSounds, clearUserErrorMessage, updateFrequentlyUsedEmojis, joinScreenShare, diff --git a/src/pages/EditRequestCreatedPage.js b/src/pages/EditRequestCreatedPage.js deleted file mode 100644 index 2b5a8abaa349..000000000000 --- a/src/pages/EditRequestCreatedPage.js +++ /dev/null @@ -1,55 +0,0 @@ -import PropTypes from 'prop-types'; -import React from 'react'; -import DatePicker from '@components/DatePicker'; -import FormProvider from '@components/Form/FormProvider'; -import InputWrapper from '@components/Form/InputWrapper'; -import HeaderWithBackButton from '@components/HeaderWithBackButton'; -import ScreenWrapper from '@components/ScreenWrapper'; -import useLocalize from '@hooks/useLocalize'; -import useThemeStyles from '@hooks/useThemeStyles'; -import CONST from '@src/CONST'; -import ONYXKEYS from '@src/ONYXKEYS'; - -const propTypes = { - /** Transaction defailt created value */ - defaultCreated: PropTypes.string.isRequired, - - /** Callback to fire when the Save button is pressed */ - onSubmit: PropTypes.func.isRequired, -}; - -function EditRequestCreatedPage({defaultCreated, onSubmit}) { - const styles = useThemeStyles(); - const {translate} = useLocalize(); - - return ( - - - - - - - ); -} - -EditRequestCreatedPage.propTypes = propTypes; -EditRequestCreatedPage.displayName = 'EditRequestCreatedPage'; - -export default EditRequestCreatedPage; diff --git a/src/pages/EditRequestPage.js b/src/pages/EditRequestPage.js index 777321fc2068..aa22439dee70 100644 --- a/src/pages/EditRequestPage.js +++ b/src/pages/EditRequestPage.js @@ -21,7 +21,6 @@ import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import EditRequestAmountPage from './EditRequestAmountPage'; import EditRequestCategoryPage from './EditRequestCategoryPage'; -import EditRequestCreatedPage from './EditRequestCreatedPage'; import EditRequestDistancePage from './EditRequestDistancePage'; import EditRequestMerchantPage from './EditRequestMerchantPage'; import EditRequestReceiptPage from './EditRequestReceiptPage'; @@ -129,19 +128,6 @@ function EditRequestPage({report, route, policy, policyCategories, policyTags, p [transaction, report, policy, policyTags, policyCategories], ); - const saveCreated = useCallback( - ({created: newCreated}) => { - // If the value hasn't changed, don't request to save changes on the server and just close the modal - if (newCreated === TransactionUtils.getCreated(transaction)) { - Navigation.dismissModal(); - return; - } - IOU.updateMoneyRequestDate(transaction.transactionID, report.reportID, newCreated, policy, policyTags, policyCategories); - Navigation.dismissModal(); - }, - [transaction, report, policy, policyTags, policyCategories], - ); - const saveMerchant = useCallback( ({merchant: newMerchant}) => { const newTrimmedMerchant = newMerchant.trim(); @@ -190,15 +176,6 @@ function EditRequestPage({report, route, policy, policyCategories, policyTags, p [transactionCategory, transaction.transactionID, report.reportID, policy, policyTags, policyCategories], ); - if (fieldToEdit === CONST.EDIT_REQUEST_FIELD.DATE) { - return ( - - ); - } - if (fieldToEdit === CONST.EDIT_REQUEST_FIELD.AMOUNT) { return ( - ); - } - if (fieldToEdit === CONST.EDIT_REQUEST_FIELD.AMOUNT) { return ( ; /** All of the personal details for everyone */ - personalDetails: PropTypes.objectOf(personalDetailsPropType), + personalDetails: OnyxEntry; - /** All reports shared with the user */ - reports: PropTypes.objectOf(reportPropTypes), + betas: OnyxEntry; /** An object that holds data about which referral banners have been dismissed */ - dismissedReferralBanners: PropTypes.objectOf(PropTypes.bool), - - ...windowDimensionsPropTypes, - - ...withLocalizePropTypes, + dismissedReferralBanners: DismissedReferralBanners; /** Whether we are searching for reports in the server */ - isSearchingForReports: PropTypes.bool, + isSearchingForReports: OnyxEntry; }; -const defaultProps = { - betas: [], - dismissedReferralBanners: {}, - personalDetails: {}, - reports: {}, - isSearchingForReports: false, +type NewChatPageProps = NewChatPageWithOnyxProps & { + isGroupChat: boolean; }; -const excludedGroupEmails = _.without(CONST.EXPENSIFY_EMAILS, CONST.EMAIL.CONCIERGE); +const excludedGroupEmails = CONST.EXPENSIFY_EMAILS.filter((value) => value !== CONST.EMAIL.CONCIERGE); -function NewChatPage({betas, isGroupChat, personalDetails, reports, translate, isSearchingForReports, dismissedReferralBanners}) { +function NewChatPage({betas, isGroupChat, personalDetails, reports, isSearchingForReports, dismissedReferralBanners}: NewChatPageProps) { + const {translate} = useLocalize(); const styles = useThemeStyles(); const [searchTerm, setSearchTerm] = useState(''); - const [filteredRecentReports, setFilteredRecentReports] = useState([]); - const [filteredPersonalDetails, setFilteredPersonalDetails] = useState([]); - const [filteredUserToInvite, setFilteredUserToInvite] = useState(); - const [selectedOptions, setSelectedOptions] = useState([]); + const [filteredRecentReports, setFilteredRecentReports] = useState([]); + const [filteredPersonalDetails, setFilteredPersonalDetails] = useState([]); + const [filteredUserToInvite, setFilteredUserToInvite] = useState(); + const [selectedOptions, setSelectedOptions] = useState([]); const {isOffline} = useNetwork(); const {isSmallScreenWidth} = useWindowDimensions(); const [didScreenTransitionEnd, setDidScreenTransitionEnd] = useState(false); @@ -76,16 +66,18 @@ function NewChatPage({betas, isGroupChat, personalDetails, reports, translate, i Boolean(filteredUserToInvite), searchTerm.trim(), maxParticipantsReached, - _.some(selectedOptions, (participant) => participant.searchText.toLowerCase().includes(searchTerm.trim().toLowerCase())), + selectedOptions.some((participant) => participant?.searchText?.toLowerCase().includes(searchTerm.trim().toLowerCase())), ); + const isOptionsDataReady = ReportUtils.isReportDataReady() && OptionsListUtils.isPersonalDetailsReady(personalDetails); - const sections = useMemo(() => { - const sectionsList = []; + const sections = useMemo((): OptionsListUtils.CategorySection[] => { + const sectionsList: OptionsListUtils.CategorySection[] = []; let indexOffset = 0; const formatResults = OptionsListUtils.formatSectionsFromSearchTerm(searchTerm, selectedOptions, filteredRecentReports, filteredPersonalDetails, maxParticipantsReached, indexOffset); sectionsList.push(formatResults.section); + indexOffset = formatResults.newIndexOffset; if (maxParticipantsReached) { @@ -95,7 +87,7 @@ function NewChatPage({betas, isGroupChat, personalDetails, reports, translate, i sectionsList.push({ title: translate('common.recents'), data: filteredRecentReports, - shouldShow: !_.isEmpty(filteredRecentReports), + shouldShow: filteredRecentReports.length > 0, indexOffset, }); indexOffset += filteredRecentReports.length; @@ -103,7 +95,7 @@ function NewChatPage({betas, isGroupChat, personalDetails, reports, translate, i sectionsList.push({ title: translate('common.contacts'), data: filteredPersonalDetails, - shouldShow: !_.isEmpty(filteredPersonalDetails), + shouldShow: filteredPersonalDetails.length > 0, indexOffset, }); indexOffset += filteredPersonalDetails.length; @@ -122,15 +114,14 @@ function NewChatPage({betas, isGroupChat, personalDetails, reports, translate, i /** * Removes a selected option from list if already selected. If not already selected add this option to the list. - * @param {Object} option */ - const toggleOption = (option) => { - const isOptionInList = _.some(selectedOptions, (selectedOption) => selectedOption.login === option.login); + const toggleOption = (option: OptionData) => { + const isOptionInList = selectedOptions.some((selectedOption) => selectedOption.login === option.login); let newSelectedOptions; if (isOptionInList) { - newSelectedOptions = _.reject(selectedOptions, (selectedOption) => selectedOption.login === option.login); + newSelectedOptions = selectedOptions.filter((selectedOption) => selectedOption.login !== option.login); } else { newSelectedOptions = [...selectedOptions, option]; } @@ -142,7 +133,7 @@ function NewChatPage({betas, isGroupChat, personalDetails, reports, translate, i } = OptionsListUtils.getFilteredOptions( reports, personalDetails, - betas, + betas ?? [], searchTerm, newSelectedOptions, isGroupChat ? excludedGroupEmails : [], @@ -166,10 +157,11 @@ function NewChatPage({betas, isGroupChat, personalDetails, reports, translate, i /** * Creates a new 1:1 chat with the option and the current user, * or navigates to the existing chat if one with those participants already exists. - * - * @param {Object} option */ - const createChat = (option) => { + const createChat = (option: OptionData) => { + if (!option.login) { + return; + } Report.navigateToAndOpenReport([option.login]); }; @@ -178,10 +170,12 @@ function NewChatPage({betas, isGroupChat, personalDetails, reports, translate, i * or navigates to the existing chat if one with those participants already exists. */ const createGroup = () => { - const logins = _.pluck(selectedOptions, 'login'); + const logins = selectedOptions.map((option) => option.login).filter((login): login is string => typeof login === 'string'); + if (logins.length < 1) { return; } + Report.navigateToAndOpenReport(logins); }; @@ -193,7 +187,7 @@ function NewChatPage({betas, isGroupChat, personalDetails, reports, translate, i } = OptionsListUtils.getFilteredOptions( reports, personalDetails, - betas, + betas ?? [], searchTerm, selectedOptions, isGroupChat ? excludedGroupEmails : [], @@ -223,6 +217,7 @@ function NewChatPage({betas, isGroupChat, personalDetails, reports, translate, i if (!interactionTask) { return; } + interactionTask.cancel(); }; }, []); @@ -251,11 +246,12 @@ function NewChatPage({betas, isGroupChat, personalDetails, reports, translate, i behavior="padding" // Offset is needed as KeyboardAvoidingView in nested inside of TabNavigator instead of wrapping whole screen. // This is because when wrapping whole screen the screen was freezing when changing Tabs. - keyboardVerticalOffset={variables.contentHeaderHeight + insets.top + variables.tabSelectorButtonHeight + variables.tabSelectorButtonPadding} + keyboardVerticalOffset={variables.contentHeaderHeight + (insets?.top ?? 0) + variables.tabSelectorButtonHeight + variables.tabSelectorButtonPadding} > 0 ? safeAreaPaddingBottomStyle : {}]}> data.dismissedReferralBanners || {}, - }, - reports: { - key: ONYXKEYS.COLLECTION.REPORT, - }, - personalDetails: { - key: ONYXKEYS.PERSONAL_DETAILS_LIST, - }, - betas: { - key: ONYXKEYS.BETAS, - }, - isSearchingForReports: { - key: ONYXKEYS.IS_SEARCHING_FOR_REPORTS, - initWithStoredValues: false, - }, - }), -)(NewChatPage); +export default withOnyx({ + dismissedReferralBanners: { + key: ONYXKEYS.ACCOUNT, + selector: (data) => data?.dismissedReferralBanners ?? {}, + }, + reports: { + key: ONYXKEYS.COLLECTION.REPORT, + }, + personalDetails: { + key: ONYXKEYS.PERSONAL_DETAILS_LIST, + }, + betas: { + key: ONYXKEYS.BETAS, + }, + isSearchingForReports: { + key: ONYXKEYS.IS_SEARCHING_FOR_REPORTS, + initWithStoredValues: false, + }, +})(NewChatPage); diff --git a/src/pages/PrivateNotes/PrivateNotesEditPage.tsx b/src/pages/PrivateNotes/PrivateNotesEditPage.tsx index eb3dd00ff802..aa02dfddd44c 100644 --- a/src/pages/PrivateNotes/PrivateNotesEditPage.tsx +++ b/src/pages/PrivateNotes/PrivateNotesEditPage.tsx @@ -22,6 +22,7 @@ import Navigation from '@libs/Navigation/Navigation'; import type {PrivateNotesNavigatorParamList} from '@libs/Navigation/types'; import * as ReportUtils from '@libs/ReportUtils'; import updateMultilineInputRange from '@libs/updateMultilineInputRange'; +import type {WithReportAndPrivateNotesOrNotFoundProps} from '@pages/home/report/withReportAndPrivateNotesOrNotFound'; import withReportAndPrivateNotesOrNotFound from '@pages/home/report/withReportAndPrivateNotesOrNotFound'; import * as ReportActions from '@userActions/Report'; import CONST from '@src/CONST'; @@ -36,7 +37,8 @@ type PrivateNotesEditPageOnyxProps = { personalDetailsList: OnyxCollection; }; -type PrivateNotesEditPageProps = PrivateNotesEditPageOnyxProps & +type PrivateNotesEditPageProps = WithReportAndPrivateNotesOrNotFoundProps & + PrivateNotesEditPageOnyxProps & StackScreenProps & { /** The report currently being looked at */ report: Report; diff --git a/src/pages/PrivateNotes/PrivateNotesListPage.tsx b/src/pages/PrivateNotes/PrivateNotesListPage.tsx index d7fb1f6497be..97ebc7dee2fb 100644 --- a/src/pages/PrivateNotes/PrivateNotesListPage.tsx +++ b/src/pages/PrivateNotes/PrivateNotesListPage.tsx @@ -1,7 +1,7 @@ import React, {useMemo} from 'react'; import {ScrollView} from 'react-native'; import {withOnyx} from 'react-native-onyx'; -import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; +import type {OnyxCollection} from 'react-native-onyx'; import type {ValueOf} from 'type-fest'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; @@ -10,24 +10,23 @@ import Text from '@components/Text'; import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; import Navigation from '@libs/Navigation/Navigation'; +import type {WithReportAndPrivateNotesOrNotFoundProps} from '@pages/home/report/withReportAndPrivateNotesOrNotFound'; import withReportAndPrivateNotesOrNotFound from '@pages/home/report/withReportAndPrivateNotesOrNotFound'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; -import type {PersonalDetails, Report, Session} from '@src/types/onyx'; +import type {PersonalDetails, Report} from '@src/types/onyx'; type PrivateNotesListPageOnyxProps = { /** All of the personal details for everyone */ personalDetailsList: OnyxCollection; - - /** Session info for the currently logged in user. */ - session: OnyxEntry; }; -type PrivateNotesListPageProps = PrivateNotesListPageOnyxProps & { - /** The report currently being looked at */ - report: Report; -}; +type PrivateNotesListPageProps = WithReportAndPrivateNotesOrNotFoundProps & + PrivateNotesListPageOnyxProps & { + /** The report currently being looked at */ + report: Report; + }; type NoteListItem = { title: string; @@ -101,8 +100,5 @@ export default withReportAndPrivateNotesOrNotFound('privateNotes.title')( personalDetailsList: { key: ONYXKEYS.PERSONAL_DETAILS_LIST, }, - session: { - key: ONYXKEYS.SESSION, - }, })(PrivateNotesListPage), ); diff --git a/src/pages/ReportDescriptionPage.tsx b/src/pages/ReportDescriptionPage.tsx index 3ccabf30c1b7..6062ef748f36 100644 --- a/src/pages/ReportDescriptionPage.tsx +++ b/src/pages/ReportDescriptionPage.tsx @@ -1,22 +1,14 @@ -import type {RouteProp} from '@react-navigation/native'; +import type {StackScreenProps} from '@react-navigation/stack'; import React from 'react'; -import type {OnyxCollection} from 'react-native-onyx'; import * as ReportUtils from '@libs/ReportUtils'; -import type * as OnyxTypes from '@src/types/onyx'; +import type {ReportDescriptionNavigatorParamList} from '@navigation/types'; +import type SCREENS from '@src/SCREENS'; +import type {WithReportOrNotFoundProps} from './home/report/withReportOrNotFound'; import withReportOrNotFound from './home/report/withReportOrNotFound'; import RoomDescriptionPage from './RoomDescriptionPage'; import TaskDescriptionPage from './tasks/TaskDescriptionPage'; -type ReportDescriptionPageProps = { - /** The report currently being looked at */ - report: OnyxTypes.Report; - - /** Policy for the current report */ - policies: OnyxCollection; - - /** Route params */ - route: RouteProp<{params: {reportID: string}}>; -}; +type ReportDescriptionPageProps = WithReportOrNotFoundProps & StackScreenProps; function ReportDescriptionPage(props: ReportDescriptionPageProps) { const isTask = ReportUtils.isTaskReport(props.report); diff --git a/src/pages/RoomMembersPage.js b/src/pages/RoomMembersPage.js index 30ffd60aa4ac..89b9ea9b7d99 100644 --- a/src/pages/RoomMembersPage.js +++ b/src/pages/RoomMembersPage.js @@ -218,6 +218,7 @@ function RoomMembersPage(props) { source: UserUtils.getAvatar(details.avatar, accountID), name: details.login, type: CONST.ICON_TYPE_AVATAR, + id: Number(accountID), }, ], }); diff --git a/src/pages/ShareCodePage.tsx b/src/pages/ShareCodePage.tsx index 7df92875b0ac..138aa729fcc2 100644 --- a/src/pages/ShareCodePage.tsx +++ b/src/pages/ShareCodePage.tsx @@ -81,7 +81,7 @@ function ShareCodePage({report, session, currentUserPersonalDetails}: ShareCodeP title={translate('common.shareCode')} onBackButtonPress={() => Navigation.goBack(isReport ? ROUTES.REPORT_WITH_ID_DETAILS.getRoute(report.reportID) : undefined)} shouldShowBackButton={isReport || isSmallScreenWidth} - icon={Illustrations.QrCode} + icon={Illustrations.QRCode} /> diff --git a/src/pages/home/ReportScreen.js b/src/pages/home/ReportScreen.js index 602fef1fca58..8741006efab1 100644 --- a/src/pages/home/ReportScreen.js +++ b/src/pages/home/ReportScreen.js @@ -206,6 +206,7 @@ function ReportScreen({ oldPolicyName: reportProp.oldPolicyName, policyName: reportProp.policyName, isOptimisticReport: reportProp.isOptimisticReport, + lastMentionedTime: reportProp.lastMentionedTime, }), [ reportProp.lastReadTime, @@ -242,6 +243,7 @@ function ReportScreen({ reportProp.oldPolicyName, reportProp.policyName, reportProp.isOptimisticReport, + reportProp.lastMentionedTime, ], ); @@ -487,6 +489,7 @@ function ReportScreen({ // eslint-disable-next-line react-hooks/exhaustive-deps }, []); + const reportIDFromParams = lodashGet(route.params, 'reportID'); // eslint-disable-next-line rulesdir/no-negated-variables const shouldShowNotFoundPage = useMemo( () => @@ -497,8 +500,9 @@ function ReportScreen({ !reportMetadata.isLoadingInitialReportActions && !isLoading && !userLeavingStatus) || - shouldHideReport, - [report, reportMetadata, isLoading, shouldHideReport, isOptimisticDelete, userLeavingStatus], + shouldHideReport || + (reportIDFromParams && !ReportUtils.isValidReportIDFromPath(reportIDFromParams)), + [report, reportMetadata, isLoading, shouldHideReport, isOptimisticDelete, userLeavingStatus, reportIDFromParams], ); const actionListValue = useMemo(() => ({flatListRef, scrollPosition, setScrollPosition}), [flatListRef, scrollPosition, setScrollPosition]); diff --git a/src/pages/home/report/ReportActionCompose/ReportActionCompose.js b/src/pages/home/report/ReportActionCompose/ReportActionCompose.js index cc07716209a2..8ec0bce9d1a7 100644 --- a/src/pages/home/report/ReportActionCompose/ReportActionCompose.js +++ b/src/pages/home/report/ReportActionCompose/ReportActionCompose.js @@ -24,6 +24,7 @@ import getDraftComment from '@libs/ComposerUtils/getDraftComment'; import * as DeviceCapabilities from '@libs/DeviceCapabilities'; import getModalState from '@libs/getModalState'; import * as ReportUtils from '@libs/ReportUtils'; +import playSound, {SOUNDS} from '@libs/Sound'; import willBlurTextInputOnTapOutsideFunc from '@libs/willBlurTextInputOnTapOutside'; import ParticipantLocalTime from '@pages/home/report/ParticipantLocalTime'; import ReportDropUI from '@pages/home/report/ReportDropUI'; @@ -255,6 +256,7 @@ function ReportActionCompose({ */ const addAttachment = useCallback( (file) => { + playSound(SOUNDS.DONE); const newComment = composerRef.current.prepareCommentAndResetComposer(); Report.addAttachment(reportID, file, newComment); setTextInputShouldClear(false); @@ -287,6 +289,7 @@ function ReportActionCompose({ return; } + playSound(SOUNDS.DONE); onSubmit(newComment); }, [onSubmit], diff --git a/src/pages/home/report/ReportActionItem.js b/src/pages/home/report/ReportActionItem.js index f960b987427b..859f9c183d80 100644 --- a/src/pages/home/report/ReportActionItem.js +++ b/src/pages/home/report/ReportActionItem.js @@ -698,6 +698,13 @@ function ReportActionItem(props) { return null; } + // We currently send whispers to all report participants and hide them in the UI for users that shouldn't see them. + // This is a temporary solution needed for comment-linking. + // The long term solution will leverage end-to-end encryption and only targeted users will be able to decrypt. + if (ReportActionsUtils.isWhisperActionTargetedToOthers(props.action)) { + return null; + } + const hasErrors = !_.isEmpty(props.action.errors); const whisperedToAccountIDs = props.action.whisperedToAccountIDs || []; const isWhisper = whisperedToAccountIDs.length > 0; diff --git a/src/pages/home/report/ReportActionItemCreated.tsx b/src/pages/home/report/ReportActionItemCreated.tsx index afe97b2b95c1..1caa951d16a7 100644 --- a/src/pages/home/report/ReportActionItemCreated.tsx +++ b/src/pages/home/report/ReportActionItemCreated.tsx @@ -117,6 +117,7 @@ export default withOnyx {}, - report: {}, - session: { - accountID: null, - }, -}; - -export default function (pageTitle) { - // eslint-disable-next-line rulesdir/no-negated-variables - return (WrappedComponent) => { - // eslint-disable-next-line rulesdir/no-negated-variables - function WithReportAndPrivateNotesOrNotFound({forwardedRef, ...props}) { - const {translate} = useLocalize(); - const {route, report, network, session} = props; - const accountID = route.params.accountID; - const isPrivateNotesFetchTriggered = !_.isUndefined(report.isLoadingPrivateNotes); - const prevIsOffline = usePrevious(network.isOffline); - const isReconnecting = prevIsOffline && !network.isOffline; - const isOtherUserNote = accountID && Number(session.accountID) !== Number(accountID); - const isPrivateNotesFetchFinished = isPrivateNotesFetchTriggered && !report.isLoadingPrivateNotes; - const isPrivateNotesEmpty = accountID ? _.has(lodashGet(report, ['privateNotes', accountID, 'note'], '')) : _.isEmpty(report.privateNotes); - - useEffect(() => { - // Do not fetch private notes if isLoadingPrivateNotes is already defined, or if network is offline. - if ((isPrivateNotesFetchTriggered && !isReconnecting) || network.isOffline) { - return; - } - - Report.getReportPrivateNote(report.reportID); - // eslint-disable-next-line react-hooks/exhaustive-deps -- do not add report.isLoadingPrivateNotes to dependencies - }, [report.reportID, network.isOffline, isPrivateNotesFetchTriggered, isReconnecting]); - - const shouldShowFullScreenLoadingIndicator = !isPrivateNotesFetchFinished || (isPrivateNotesEmpty && (report.isLoadingPrivateNotes || !isOtherUserNote)); - - // eslint-disable-next-line rulesdir/no-negated-variables - const shouldShowNotFoundPage = useMemo(() => { - // Show not found view if the report is archived, or if the note is not of current user. - if (ReportUtils.isArchivedRoom(report) || (accountID && Number(session.accountID) !== Number(accountID))) { - return true; - } - - // Don't show not found view if the notes are still loading, or if the notes are non-empty. - if (shouldShowFullScreenLoadingIndicator || !isPrivateNotesEmpty || isReconnecting) { - return false; - } - - // As notes being empty and not loading is a valid case, show not found view only in offline mode. - return network.isOffline; - }, [report, network.isOffline, accountID, session.accountID, isPrivateNotesEmpty, shouldShowFullScreenLoadingIndicator, isReconnecting]); - - if (shouldShowFullScreenLoadingIndicator) { - return ; - } - - if (shouldShowNotFoundPage) { - return ; - } - - return ( - - ); - } - - WithReportAndPrivateNotesOrNotFound.propTypes = propTypes; - WithReportAndPrivateNotesOrNotFound.defaultProps = defaultProps; - WithReportAndPrivateNotesOrNotFound.displayName = `withReportAndPrivateNotesOrNotFound(${getComponentDisplayName(WrappedComponent)})`; - - // eslint-disable-next-line rulesdir/no-negated-variables - const WithReportAndPrivateNotesOrNotFoundWithRef = React.forwardRef((props, ref) => ( - - )); - - WithReportAndPrivateNotesOrNotFoundWithRef.displayName = 'WithReportAndPrivateNotesOrNotFoundWithRef'; - - return compose( - withReportOrNotFound(), - withOnyx({ - session: { - key: ONYXKEYS.SESSION, - }, - }), - withNetwork(), - )(WithReportAndPrivateNotesOrNotFoundWithRef); - }; -} diff --git a/src/pages/home/report/withReportAndPrivateNotesOrNotFound.tsx b/src/pages/home/report/withReportAndPrivateNotesOrNotFound.tsx new file mode 100644 index 000000000000..d8d461568a45 --- /dev/null +++ b/src/pages/home/report/withReportAndPrivateNotesOrNotFound.tsx @@ -0,0 +1,102 @@ +import React, {useEffect, useMemo} from 'react'; +import type {ComponentType, ForwardedRef, RefAttributes} from 'react'; +import type {OnyxEntry} from 'react-native-onyx'; +import {withOnyx} from 'react-native-onyx'; +import useLocalize from '@hooks/useLocalize'; +import useNetwork from '@hooks/useNetwork'; +import usePrevious from '@hooks/usePrevious'; +import * as Report from '@libs/actions/Report'; +import getComponentDisplayName from '@libs/getComponentDisplayName'; +import * as ReportUtils from '@libs/ReportUtils'; +import NotFoundPage from '@pages/ErrorPage/NotFoundPage'; +import LoadingPage from '@pages/LoadingPage'; +import type {TranslationPaths} from '@src/languages/types'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type * as OnyxTypes from '@src/types/onyx'; +import {isEmptyObject} from '@src/types/utils/EmptyObject'; +import type {WithReportOrNotFoundOnyxProps, WithReportOrNotFoundProps} from './withReportOrNotFound'; +import withReportOrNotFound from './withReportOrNotFound'; + +type WithReportAndPrivateNotesOrNotFoundOnyxProps = { + /** Session of currently logged in user */ + session: OnyxEntry; +}; + +type WithReportAndPrivateNotesOrNotFoundProps = WithReportAndPrivateNotesOrNotFoundOnyxProps & WithReportOrNotFoundProps; + +export default function (pageTitle: TranslationPaths) { + // eslint-disable-next-line rulesdir/no-negated-variables + return ( + WrappedComponent: ComponentType>, + ): React.ComponentType & RefAttributes, keyof WithReportOrNotFoundOnyxProps>> => { + // eslint-disable-next-line rulesdir/no-negated-variables + function WithReportAndPrivateNotesOrNotFound(props: TProps, ref: ForwardedRef) { + const {translate} = useLocalize(); + const {isOffline} = useNetwork(); + const {route, report, session} = props; + const accountID = ('accountID' in route.params && route.params.accountID) || ''; + const isPrivateNotesFetchTriggered = report.isLoadingPrivateNotes !== undefined; + const prevIsOffline = usePrevious(isOffline); + const isReconnecting = prevIsOffline && !isOffline; + const isOtherUserNote = !!accountID && Number(session?.accountID) !== Number(accountID); + const isPrivateNotesFetchFinished = isPrivateNotesFetchTriggered && !report.isLoadingPrivateNotes; + const isPrivateNotesEmpty = accountID ? !report?.privateNotes?.[Number(accountID)]?.note : isEmptyObject(report?.privateNotes); + + useEffect(() => { + // Do not fetch private notes if isLoadingPrivateNotes is already defined, or if network is offline. + if ((isPrivateNotesFetchTriggered && !isReconnecting) || isOffline) { + return; + } + + Report.getReportPrivateNote(report.reportID); + // eslint-disable-next-line react-hooks/exhaustive-deps -- do not add report.isLoadingPrivateNotes to dependencies + }, [report.reportID, isOffline, isPrivateNotesFetchTriggered, isReconnecting]); + + const shouldShowFullScreenLoadingIndicator = !isPrivateNotesFetchFinished; + + // eslint-disable-next-line rulesdir/no-negated-variables + const shouldShowNotFoundPage = useMemo(() => { + // Show not found view if the report is archived, or if the note is not of current user. + if (ReportUtils.isArchivedRoom(report) || isOtherUserNote) { + return true; + } + + // Don't show not found view if the notes are still loading, or if the notes are non-empty. + if (shouldShowFullScreenLoadingIndicator || !isPrivateNotesEmpty || isReconnecting) { + return false; + } + + // As notes being empty and not loading is a valid case, show not found view only in offline mode. + return isOffline; + }, [report, isOtherUserNote, shouldShowFullScreenLoadingIndicator, isPrivateNotesEmpty, isReconnecting, isOffline]); + + if (shouldShowFullScreenLoadingIndicator) { + return ; + } + + if (shouldShowNotFoundPage) { + return ; + } + + return ( + + ); + } + + WithReportAndPrivateNotesOrNotFound.displayName = `withReportAndPrivateNotesOrNotFound(${getComponentDisplayName(WrappedComponent)})`; + + return withReportOrNotFound()( + withOnyx, WithReportAndPrivateNotesOrNotFoundOnyxProps>({ + session: { + key: ONYXKEYS.SESSION, + }, + })(WithReportAndPrivateNotesOrNotFound), + ); + }; +} + +export type {WithReportAndPrivateNotesOrNotFoundProps}; diff --git a/src/pages/home/report/withReportOrNotFound.tsx b/src/pages/home/report/withReportOrNotFound.tsx index 94000f8a6bd8..8054245e1b71 100644 --- a/src/pages/home/report/withReportOrNotFound.tsx +++ b/src/pages/home/report/withReportOrNotFound.tsx @@ -7,9 +7,11 @@ import {withOnyx} from 'react-native-onyx'; import FullscreenLoadingIndicator from '@components/FullscreenLoadingIndicator'; import getComponentDisplayName from '@libs/getComponentDisplayName'; import * as ReportUtils from '@libs/ReportUtils'; +import type {PrivateNotesNavigatorParamList, ReportDescriptionNavigatorParamList} from '@navigation/types'; import NotFoundPage from '@pages/ErrorPage/NotFoundPage'; import * as Report from '@userActions/Report'; import ONYXKEYS from '@src/ONYXKEYS'; +import type SCREENS from '@src/SCREENS'; import type * as OnyxTypes from '@src/types/onyx'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; @@ -17,6 +19,9 @@ type WithReportOrNotFoundOnyxProps = { /** The report currently being looked at */ report: OnyxEntry; + /** Metadata of the report currently being looked at */ + reportMetadata: OnyxEntry; + /** The policies which the user has access to */ policies: OnyxCollection; @@ -28,7 +33,7 @@ type WithReportOrNotFoundOnyxProps = { }; type WithReportOrNotFoundProps = WithReportOrNotFoundOnyxProps & { - route: RouteProp<{params: {reportID: string}}>; + route: RouteProp | RouteProp; /** The report currently being looked at */ report: OnyxTypes.Report; @@ -42,28 +47,29 @@ export default function ( return function (WrappedComponent: ComponentType>) { function WithReportOrNotFound(props: TProps, ref: ForwardedRef) { const contentShown = React.useRef(false); + const isReportIdInRoute = !!props.route.params.reportID?.length; + const isReportLoaded = !isEmptyObject(props.report) && !!props.report?.reportID; - const isReportIdInRoute = props.route.params.reportID?.length; + // The `isLoadingInitialReportActions` value will become `false` only after the first OpenReport API call is finished (either succeeded or failed) + const shouldFetchReport = isReportIdInRoute && props.reportMetadata?.isLoadingInitialReportActions !== false; // When accessing certain report-dependant pages (e.g. Task Title) by deeplink, the OpenReport API is not called, // So we need to call OpenReport API here to make sure the report data is loaded if it exists on the Server useEffect(() => { - if (!isReportIdInRoute || !isEmptyObject(props.report)) { + if (isReportLoaded || !shouldFetchReport) { // If the report is not required or is already loaded, we don't need to call the API return; } Report.openReport(props.route.params.reportID); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [isReportIdInRoute, props.route.params.reportID]); + }, [shouldFetchReport, isReportLoaded, props.route.params.reportID]); if (shouldRequireReportID || isReportIdInRoute) { - const shouldShowFullScreenLoadingIndicator = props.isLoadingReportData !== false && (!Object.entries(props.report ?? {}).length || !props.report?.reportID); - - const shouldShowNotFoundPage = - !Object.entries(props.report ?? {}).length || !props.report?.reportID || !ReportUtils.canAccessReport(props.report, props.policies, props.betas); + const shouldShowFullScreenLoadingIndicator = !isReportLoaded && (props.isLoadingReportData !== false || shouldFetchReport); + const shouldShowNotFoundPage = !isReportLoaded || !ReportUtils.canAccessReport(props.report, props.policies, props.betas); - // If the content was shown but it's not anymore that means the report was deleted and we are probably navigating out of this screen. + // If the content was shown, but it's not anymore, that means the report was deleted, and we are probably navigating out of this screen. // Return null for this case to avoid rendering FullScreenLoadingIndicator or NotFoundPage when animating transition. if (shouldShowNotFoundPage && contentShown.current) { return null; @@ -97,6 +103,9 @@ export default function ( report: { key: ({route}) => `${ONYXKEYS.COLLECTION.REPORT}${route.params.reportID}`, }, + reportMetadata: { + key: ({route}) => `${ONYXKEYS.COLLECTION.REPORT_METADATA}${route.params.reportID}`, + }, isLoadingReportData: { key: ONYXKEYS.IS_LOADING_REPORT_DATA, }, @@ -110,4 +119,4 @@ export default function ( }; } -export type {WithReportOrNotFoundProps}; +export type {WithReportOrNotFoundProps, WithReportOrNotFoundOnyxProps}; diff --git a/src/pages/home/sidebar/SidebarLinks.js b/src/pages/home/sidebar/SidebarLinks.js index 773d6b6d4acc..2fe44639e184 100644 --- a/src/pages/home/sidebar/SidebarLinks.js +++ b/src/pages/home/sidebar/SidebarLinks.js @@ -1,7 +1,9 @@ /* eslint-disable rulesdir/onyx-props-must-have-default */ +import lodashGet from 'lodash/get'; import PropTypes from 'prop-types'; import React, {useCallback, useEffect, useRef} from 'react'; import {InteractionManager, StyleSheet, View} from 'react-native'; +import {withOnyx} from 'react-native-onyx'; import _ from 'underscore'; import Breadcrumbs from '@components/Breadcrumbs'; import LHNOptionsList from '@components/LHNOptionsList/LHNOptionsList'; @@ -42,7 +44,7 @@ const propTypes = { isActiveReport: PropTypes.func.isRequired, }; -function SidebarLinks({onLinkClick, insets, optionListItems, isLoading, priorityMode = CONST.PRIORITY_MODE.DEFAULT, isActiveReport, isCreateMenuOpen}) { +function SidebarLinks({onLinkClick, insets, optionListItems, isLoading, priorityMode = CONST.PRIORITY_MODE.DEFAULT, isActiveReport, isCreateMenuOpen, activePolicy}) { const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); const modal = useRef({}); @@ -133,9 +135,14 @@ function SidebarLinks({onLinkClick, insets, optionListItems, isLoading, priority `${ONYXKEYS.COLLECTION.POLICY}${activeWorkspaceID}`, + }, +})(SidebarLinks); export {basePropTypes}; diff --git a/src/pages/home/sidebar/SidebarLinksData.js b/src/pages/home/sidebar/SidebarLinksData.js index abe6d66b3759..3bd538e8beab 100644 --- a/src/pages/home/sidebar/SidebarLinksData.js +++ b/src/pages/home/sidebar/SidebarLinksData.js @@ -208,6 +208,7 @@ function SidebarLinksData({ isActiveReport={isActiveReport} isLoading={isLoading} optionListItems={optionListItemsWithCurrentReport} + activeWorkspaceID={activeWorkspaceID} /> ); diff --git a/src/pages/iou/MoneyRequestDatePage.js b/src/pages/iou/MoneyRequestDatePage.js deleted file mode 100644 index f6159abd73f6..000000000000 --- a/src/pages/iou/MoneyRequestDatePage.js +++ /dev/null @@ -1,126 +0,0 @@ -import lodashGet from 'lodash/get'; -import PropTypes from 'prop-types'; -import React, {useEffect} from 'react'; -import {withOnyx} from 'react-native-onyx'; -import _ from 'underscore'; -import DatePicker from '@components/DatePicker'; -import FormProvider from '@components/Form/FormProvider'; -import InputWrapper from '@components/Form/InputWrapper'; -import HeaderWithBackButton from '@components/HeaderWithBackButton'; -import ScreenWrapper from '@components/ScreenWrapper'; -import useLocalize from '@hooks/useLocalize'; -import useThemeStyles from '@hooks/useThemeStyles'; -import * as MoneyRequestUtils from '@libs/MoneyRequestUtils'; -import Navigation from '@libs/Navigation/Navigation'; -import * as IOU from '@userActions/IOU'; -import CONST from '@src/CONST'; -import ONYXKEYS from '@src/ONYXKEYS'; -import ROUTES from '@src/ROUTES'; -import {iouDefaultProps, iouPropTypes} from './propTypes'; - -const propTypes = { - /** Onyx Props */ - /** Holds data related to Money Request view state, rather than the underlying Money Request data. */ - iou: iouPropTypes, - - /** Route from navigation */ - route: PropTypes.shape({ - /** Params from the route */ - params: PropTypes.shape({ - /** The type of IOU report, i.e. bill, request, send */ - iouType: PropTypes.string, - - /** The report ID of the IOU */ - reportID: PropTypes.string, - - /** Which field we are editing */ - field: PropTypes.string, - - /** reportID for the "transaction thread" */ - threadReportID: PropTypes.string, - }), - }).isRequired, - - /** The current tab we have navigated to in the request modal. String that corresponds to the request type. */ - selectedTab: PropTypes.oneOf(_.values(CONST.TAB_REQUEST)).isRequired, -}; - -const defaultProps = { - iou: iouDefaultProps, -}; - -function MoneyRequestDatePage({iou, route, selectedTab}) { - const styles = useThemeStyles(); - const {translate} = useLocalize(); - const iouType = lodashGet(route, 'params.iouType', ''); - const reportID = lodashGet(route, 'params.reportID', ''); - const isDistanceRequest = MoneyRequestUtils.isDistanceRequest(iouType, selectedTab); - - useEffect(() => { - const moneyRequestId = `${iouType}${reportID}`; - const shouldReset = iou.id !== moneyRequestId; - if (shouldReset) { - IOU.resetMoneyRequestInfo(moneyRequestId); - } - - if (!isDistanceRequest && (_.isEmpty(iou.participants) || (iou.amount === 0 && !iou.receiptPath) || shouldReset)) { - Navigation.goBack(ROUTES.MONEY_REQUEST.getRoute(iouType, reportID), true); - } - }, [iou.id, iou.participants, iou.amount, iou.receiptPath, iouType, reportID, isDistanceRequest]); - - function navigateBack() { - Navigation.goBack(ROUTES.MONEY_REQUEST_CONFIRMATION.getRoute(iouType, reportID)); - } - - /** - * Sets the money request comment by saving it to Onyx. - * - * @param {Object} value - * @param {String} value.moneyRequestCreated - */ - function updateDate(value) { - IOU.setMoneyRequestCreated(value.moneyRequestCreated); - navigateBack(); - } - - return ( - - navigateBack()} - /> - updateDate(value)} - submitButtonText={translate('common.save')} - enabledWhenOffline - > - - - - ); -} - -MoneyRequestDatePage.propTypes = propTypes; -MoneyRequestDatePage.defaultProps = defaultProps; -MoneyRequestDatePage.displayName = 'MoneyRequestDatePage'; - -export default withOnyx({ - iou: { - key: ONYXKEYS.IOU, - }, - selectedTab: { - key: `${ONYXKEYS.COLLECTION.SELECTED_TAB}${CONST.TAB.RECEIPT_TAB_ID}`, - }, -})(MoneyRequestDatePage); diff --git a/src/pages/iou/request/step/IOURequestStepDate.js b/src/pages/iou/request/step/IOURequestStepDate.js index 5677ef46fcfa..d25ca4d26706 100644 --- a/src/pages/iou/request/step/IOURequestStepDate.js +++ b/src/pages/iou/request/step/IOURequestStepDate.js @@ -1,16 +1,24 @@ +import lodashGet from 'lodash/get'; +import lodashIsEmpty from 'lodash/isEmpty'; +import PropTypes from 'prop-types'; import React from 'react'; +import {withOnyx} from 'react-native-onyx'; +import categoryPropTypes from '@components/categoryPropTypes'; import DatePicker from '@components/DatePicker'; import FormProvider from '@components/Form/FormProvider'; import InputWrapper from '@components/Form/InputWrapper'; +import tagPropTypes from '@components/tagPropTypes'; import transactionPropTypes from '@components/transactionPropTypes'; import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; import compose from '@libs/compose'; import * as IOUUtils from '@libs/IOUUtils'; import Navigation from '@libs/Navigation/Navigation'; +import * as TransactionUtils from '@libs/TransactionUtils'; import * as IOU from '@userActions/IOU'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; +import {policyPropTypes} from '@src/pages/workspace/withPolicy'; import IOURequestStepRoutePropTypes from './IOURequestStepRoutePropTypes'; import StepScreenWrapper from './StepScreenWrapper'; import withFullTransactionOrNotFound from './withFullTransactionOrNotFound'; @@ -23,20 +31,44 @@ const propTypes = { /** Onyx Props */ /** Holds data related to Money Request view state, rather than the underlying Money Request data. */ transaction: transactionPropTypes, + + /** The draft transaction that holds data to be persisted on the current transaction */ + splitDraftTransaction: transactionPropTypes, + + /** The policy of the report */ + policy: policyPropTypes.policy, + + /** Collection of categories attached to a policy */ + policyCategories: PropTypes.objectOf(categoryPropTypes), + + /** Collection of tags attached to a policy */ + policyTags: tagPropTypes, }; const defaultProps = { transaction: {}, + splitDraftTransaction: {}, + policy: null, + policyTags: null, + policyCategories: null, }; function IOURequestStepDate({ route: { - params: {iouType, backTo, transactionID}, + params: {action, iouType, reportID, backTo}, }, transaction, + splitDraftTransaction, + policy, + policyTags, + policyCategories, }) { const styles = useThemeStyles(); const {translate} = useLocalize(); + const isEditing = action === CONST.IOU.ACTION.EDIT; + // In the split flow, when editing we use SPLIT_TRANSACTION_DRAFT to save draft value + const isEditingSplitBill = iouType === CONST.IOU.TYPE.SPLIT && isEditing; + const currentCreated = isEditingSplitBill && !lodashIsEmpty(splitDraftTransaction) ? TransactionUtils.getCreated(splitDraftTransaction) : TransactionUtils.getCreated(transaction); const navigateBack = () => { Navigation.goBack(backTo); @@ -47,7 +79,27 @@ function IOURequestStepDate({ * @param {String} value.moneyRequestCreated */ const updateDate = (value) => { - IOU.setMoneyRequestCreated_temporaryForRefactor(transactionID, value.moneyRequestCreated); + const newCreated = value.moneyRequestCreated; + + // Only update created if it has changed + if (newCreated === currentCreated) { + navigateBack(); + return; + } + + // In the split flow, when editing we use SPLIT_TRANSACTION_DRAFT to save draft value + if (isEditingSplitBill) { + IOU.setDraftSplitTransaction(transaction.transactionID, {created: newCreated}); + navigateBack(); + return; + } + + IOU.setMoneyRequestCreated(transaction.transactionID, newCreated, action === CONST.IOU.ACTION.CREATE); + + if (isEditing) { + IOU.updateMoneyRequestDate(transaction.transactionID, reportID, newCreated, policy, policyTags, policyCategories); + } + navigateBack(); }; @@ -70,7 +122,7 @@ function IOURequestStepDate({ InputComponent={DatePicker} inputID="moneyRequestCreated" label={translate('common.date')} - defaultValue={transaction.created} + defaultValue={currentCreated} maxDate={CONST.CALENDAR_PICKER.MAX_DATE} minDate={CONST.CALENDAR_PICKER.MIN_DATE} /> @@ -83,4 +135,24 @@ IOURequestStepDate.propTypes = propTypes; IOURequestStepDate.defaultProps = defaultProps; IOURequestStepDate.displayName = 'IOURequestStepDate'; -export default compose(withWritableReportOrNotFound, withFullTransactionOrNotFound)(IOURequestStepDate); +export default compose( + withWritableReportOrNotFound, + withFullTransactionOrNotFound, + withOnyx({ + splitDraftTransaction: { + key: ({route}) => { + const transactionID = lodashGet(route, 'params.transactionID', 0); + return `${ONYXKEYS.COLLECTION.SPLIT_TRANSACTION_DRAFT}${transactionID}`; + }, + }, + policy: { + key: ({report}) => `${ONYXKEYS.COLLECTION.POLICY}${report ? report.policyID : '0'}`, + }, + policyCategories: { + key: ({report}) => `${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${report ? report.policyID : '0'}`, + }, + policyTags: { + key: ({report}) => `${ONYXKEYS.COLLECTION.POLICY_TAGS}${report ? report.policyID : '0'}`, + }, + }), +)(IOURequestStepDate); diff --git a/src/pages/iou/request/step/IOURequestStepScan/index.native.js b/src/pages/iou/request/step/IOURequestStepScan/index.native.js index a1a3ed946967..b23420b5ef69 100644 --- a/src/pages/iou/request/step/IOURequestStepScan/index.native.js +++ b/src/pages/iou/request/step/IOURequestStepScan/index.native.js @@ -65,9 +65,26 @@ function IOURequestStepScan({ const camera = useRef(null); const [flash, setFlash] = useState(false); const [cameraPermissionStatus, setCameraPermissionStatus] = useState(undefined); + const askedForPermission = useRef(false); const {translate} = useLocalize(); + const askForPermissions = (showPermissionsAlert = true) => { + // There's no way we can check for the BLOCKED status without requesting the permission first + // https://github.com/zoontek/react-native-permissions/blob/a836e114ce3a180b2b23916292c79841a267d828/README.md?plain=1#L670 + CameraPermission.requestCameraPermission() + .then((status) => { + setCameraPermissionStatus(status); + + if (status === RESULTS.BLOCKED && showPermissionsAlert) { + FileUtils.showCameraPermissionsAlert(); + } + }) + .catch(() => { + setCameraPermissionStatus(RESULTS.UNAVAILABLE); + }); + }; + const focusIndicatorOpacity = useSharedValue(0); const focusIndicatorScale = useSharedValue(2); const focusIndicatorPosition = useSharedValue({x: 0, y: 0}); @@ -104,14 +121,22 @@ function IOURequestStepScan({ }); useEffect(() => { - const refreshCameraPermissionStatus = () => { + const refreshCameraPermissionStatus = (shouldAskForPermission = false) => { CameraPermission.getCameraPermissionStatus() - .then(setCameraPermissionStatus) + .then((res) => { + // In android device app data, the status is not set to blocked until denied twice, + // due to that the app will ask for permission twice whenever users opens uses the scan tab + setCameraPermissionStatus(res); + if (shouldAskForPermission && !askedForPermission.current) { + askedForPermission.current = true; + askForPermissions(false); + } + }) .catch(() => setCameraPermissionStatus(RESULTS.UNAVAILABLE)); }; // Check initial camera permission status - refreshCameraPermissionStatus(); + refreshCameraPermissionStatus(true); // Refresh permission status when app gain focus const subscription = AppState.addEventListener('change', (appState) => { @@ -146,22 +171,6 @@ function IOURequestStepScan({ return true; }; - const askForPermissions = () => { - // There's no way we can check for the BLOCKED status without requesting the permission first - // https://github.com/zoontek/react-native-permissions/blob/a836e114ce3a180b2b23916292c79841a267d828/README.md?plain=1#L670 - CameraPermission.requestCameraPermission() - .then((status) => { - setCameraPermissionStatus(status); - - if (status === RESULTS.BLOCKED) { - FileUtils.showCameraPermissionsAlert(); - } - }) - .catch(() => { - setCameraPermissionStatus(RESULTS.UNAVAILABLE); - }); - }; - const navigateBack = () => { Navigation.goBack(); }; @@ -213,6 +222,11 @@ function IOURequestStepScan({ }; const capturePhoto = useCallback(() => { + if (!camera.current && (cameraPermissionStatus === RESULTS.DENIED || cameraPermissionStatus === RESULTS.BLOCKED)) { + askForPermissions(cameraPermissionStatus !== RESULTS.DENIED); + return; + } + const showCameraAlert = () => { Alert.alert(translate('receipt.cameraErrorTitle'), translate('receipt.cameraErrorMessage')); }; @@ -245,7 +259,7 @@ function IOURequestStepScan({ showCameraAlert(); Log.warn('Error taking photo', error); }); - }, [flash, action, translate, transactionID, updateScanAndNavigate, navigateToConfirmationStep]); + }, [flash, action, translate, transactionID, updateScanAndNavigate, navigateToConfirmationStep, cameraPermissionStatus]); // Wait for camera permission status to render if (cameraPermissionStatus == null) { @@ -278,7 +292,7 @@ function IOURequestStepScan({ text={translate('common.continue')} accessibilityLabel={translate('common.continue')} style={[styles.p9, styles.pt5]} - onPress={askForPermissions} + onPress={capturePhoto} /> )} diff --git a/src/pages/settings/AboutPage/AboutPage.tsx b/src/pages/settings/AboutPage/AboutPage.tsx index a78d248d1d4c..26d845a6a921 100644 --- a/src/pages/settings/AboutPage/AboutPage.tsx +++ b/src/pages/settings/AboutPage/AboutPage.tsx @@ -1,17 +1,18 @@ import React, {useCallback, useMemo, useRef} from 'react'; -import {View} from 'react-native'; +import {ScrollView, View} from 'react-native'; // eslint-disable-next-line no-restricted-imports -import type {GestureResponderEvent, Text as RNText} from 'react-native'; +import type {GestureResponderEvent, Text as RNText, StyleProp, ViewStyle} from 'react-native'; import DeviceInfo from 'react-native-device-info'; +import HeaderWithBackButton from '@components/HeaderWithBackButton'; import * as Expensicons from '@components/Icon/Expensicons'; import * as Illustrations from '@components/Icon/Illustrations'; -import IllustratedHeaderPageLayout from '@components/IllustratedHeaderPageLayout'; import LottieAnimations from '@components/LottieAnimations'; import MenuItemList from '@components/MenuItemList'; +import ScreenWrapper from '@components/ScreenWrapper'; +import Section from '@components/Section'; import Text from '@components/Text'; import TextLink from '@components/TextLink'; import useLocalize from '@hooks/useLocalize'; -import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import useWaitForNavigation from '@hooks/useWaitForNavigation'; import useWindowDimensions from '@hooks/useWindowDimensions'; @@ -23,7 +24,6 @@ import * as Report from '@userActions/Report'; import CONST from '@src/CONST'; import type {TranslationPaths} from '@src/languages/types'; import ROUTES from '@src/ROUTES'; -import SCREENS from '@src/SCREENS'; import type IconAsset from '@src/types/utils/IconAsset'; import pkg from '../../../../package.json'; @@ -44,11 +44,11 @@ type MenuItem = { iconRight?: IconAsset; action: () => Promise; link?: string; + wrapperStyle?: StyleProp; }; function AboutPage() { const {translate} = useLocalize(); - const theme = useTheme(); const styles = useThemeStyles(); const popoverAnchor = useRef(null); const waitForNavigate = useWaitForNavigation(); @@ -105,15 +105,16 @@ function AboutPage() { : undefined, ref: popoverAnchor, shouldBlockSelection: !!link, + wrapperStyle: [styles.sectionMenuItemTopDescription], })); - }, [translate, waitForNavigate]); + }, [styles, translate, waitForNavigate]); const overlayContent = useCallback( () => ( - + v{Environment.isInternalTestBuild() ? `${pkg.version} PR:${CONST.PULL_REQUEST_NUMBER}${getFlavor()}` : `${pkg.version}${getFlavor()}`} @@ -125,48 +126,60 @@ function AboutPage() { ); return ( - Navigation.goBack(ROUTES.SETTINGS)} - shouldShowBackButton={isSmallScreenWidth} - illustration={LottieAnimations.Coin} - backgroundColor={theme.PAGE_THEMES[SCREENS.SETTINGS.ABOUT].backgroundColor} - overlayContent={overlayContent} + - - {translate('footer.aboutExpensify')} - {translate('initialSettingsPage.aboutPage.description')} - - Navigation.goBack(ROUTES.SETTINGS)} + icon={Illustrations.PalmTree} /> - - - {translate('initialSettingsPage.readTheTermsAndPrivacy.phrase1')}{' '} - + +
- {translate('initialSettingsPage.readTheTermsAndPrivacy.phrase2')} - {' '} - {translate('initialSettingsPage.readTheTermsAndPrivacy.phrase3')}{' '} - + + +
+
+ + - {translate('initialSettingsPage.readTheTermsAndPrivacy.phrase4')} -
- . -
-
-
+ {translate('initialSettingsPage.readTheTermsAndPrivacy.phrase1')}{' '} + + {translate('initialSettingsPage.readTheTermsAndPrivacy.phrase2')} + {' '} + {translate('initialSettingsPage.readTheTermsAndPrivacy.phrase3')}{' '} + + {translate('initialSettingsPage.readTheTermsAndPrivacy.phrase4')} + + . + +
+
+ ); } diff --git a/src/pages/settings/InitialSettingsPage.js b/src/pages/settings/InitialSettingsPage.js index e0f414910d7b..10f5dbef0e5a 100755 --- a/src/pages/settings/InitialSettingsPage.js +++ b/src/pages/settings/InitialSettingsPage.js @@ -173,6 +173,8 @@ function InitialSettingsPage(props) { Link.openOldDotLink(CONST.OLDDOT_URLS.INBOX); }, link: () => Link.buildOldDotURL(CONST.OLDDOT_URLS.INBOX), + iconRight: Expensicons.NewWindow, + shouldShowRightIcon: true, }, { translationKey: 'initialSettingsPage.signOut', @@ -222,6 +224,8 @@ function InitialSettingsPage(props) { action: () => { Link.openExternalLink(CONST.NEWHELP_URL); }, + iconRight: Expensicons.NewWindow, + shouldShowRightIcon: true, link: CONST.NEWHELP_URL, }, { @@ -292,6 +296,8 @@ function InitialSettingsPage(props) { onSecondaryInteraction={item.link ? (event) => openPopover(item.link, event) : undefined} focused={activeRoute && item.routeName && activeRoute.toLowerCase().replaceAll('_', '') === item.routeName.toLowerCase().replaceAll('/', '')} isPaneMenu + iconRight={item.iconRight} + shouldShowRightIcon={item.shouldShowRightIcon} /> ); })} diff --git a/src/pages/settings/Preferences/PreferencesPage.js b/src/pages/settings/Preferences/PreferencesPage.js index 6ea7e1eb5280..5ac78f6d20c6 100755 --- a/src/pages/settings/Preferences/PreferencesPage.js +++ b/src/pages/settings/Preferences/PreferencesPage.js @@ -1,18 +1,19 @@ import lodashGet from 'lodash/get'; import PropTypes from 'prop-types'; import React from 'react'; -import {View} from 'react-native'; +import {ScrollView, View} from 'react-native'; import {withOnyx} from 'react-native-onyx'; +import HeaderWithBackButton from '@components/HeaderWithBackButton'; import * as Illustrations from '@components/Icon/Illustrations'; -import IllustratedHeaderPageLayout from '@components/IllustratedHeaderPageLayout'; import LottieAnimations from '@components/LottieAnimations'; import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; +import ScreenWrapper from '@components/ScreenWrapper'; +import Section from '@components/Section'; import Switch from '@components/Switch'; import TestToolMenu from '@components/TestToolMenu'; import Text from '@components/Text'; import useEnvironment from '@hooks/useEnvironment'; import useLocalize from '@hooks/useLocalize'; -import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import useWindowDimensions from '@hooks/useWindowDimensions'; import Navigation from '@libs/Navigation/Navigation'; @@ -20,7 +21,6 @@ import * as User from '@userActions/User'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; -import SCREENS from '@src/SCREENS'; const propTypes = { /** The chat priority mode */ @@ -43,67 +43,104 @@ const defaultProps = { }; function PreferencesPage(props) { - const theme = useTheme(); const styles = useThemeStyles(); const {isProduction} = useEnvironment(); const {translate, preferredLocale} = useLocalize(); const {isSmallScreenWidth} = useWindowDimensions(); return ( - - - - {translate('common.notifications')} - - - - {translate('preferencesPage.receiveRelevantFeatureUpdatesAndExpensifyNews')} - - - - + Navigation.goBack()} + /> + + +
+ + + {translate('common.notifications')} + + + + {translate('preferencesPage.receiveRelevantFeatureUpdatesAndExpensifyNews')} + + + + + + + + {translate('preferencesPage.muteAllSounds')} + + + + + + Navigation.navigate(ROUTES.SETTINGS_PRIORITY_MODE)} + wrapperStyle={styles.sectionMenuItemTopDescription} + /> + Navigation.navigate(ROUTES.SETTINGS_LANGUAGE)} + wrapperStyle={styles.sectionMenuItemTopDescription} + /> + Navigation.navigate(ROUTES.SETTINGS_THEME)} + wrapperStyle={styles.sectionMenuItemTopDescription} + /> + +
+ {!isProduction && ( +
+ + + +
+ )}
- Navigation.navigate(ROUTES.SETTINGS_PRIORITY_MODE)} - /> - Navigation.navigate(ROUTES.SETTINGS_LANGUAGE)} - /> - Navigation.navigate(ROUTES.SETTINGS_THEME)} - /> - {/* Enable additional test features in non-production environments */} - {!isProduction && ( - - - - )} -
-
+ + ); } diff --git a/src/pages/settings/Security/SecuritySettingsPage.tsx b/src/pages/settings/Security/SecuritySettingsPage.tsx index 5bac2a23eb8d..8600c9e08471 100644 --- a/src/pages/settings/Security/SecuritySettingsPage.tsx +++ b/src/pages/settings/Security/SecuritySettingsPage.tsx @@ -1,22 +1,21 @@ import React, {useMemo} from 'react'; import {ScrollView, View} from 'react-native'; +import HeaderWithBackButton from '@components/HeaderWithBackButton'; import * as Expensicons from '@components/Icon/Expensicons'; import * as Illustrations from '@components/Icon/Illustrations'; -import IllustratedHeaderPageLayout from '@components/IllustratedHeaderPageLayout'; import LottieAnimations from '@components/LottieAnimations'; import MenuItemList from '@components/MenuItemList'; +import ScreenWrapper from '@components/ScreenWrapper'; +import Section from '@components/Section'; import useLocalize from '@hooks/useLocalize'; -import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import useWaitForNavigation from '@hooks/useWaitForNavigation'; import useWindowDimensions from '@hooks/useWindowDimensions'; import Navigation from '@libs/Navigation/Navigation'; import type {TranslationPaths} from '@src/languages/types'; import ROUTES from '@src/ROUTES'; -import SCREENS from '@src/SCREENS'; function SecuritySettingsPage() { - const theme = useTheme(); const styles = useThemeStyles(); const {translate} = useLocalize(); const waitForNavigate = useWaitForNavigation(); @@ -43,29 +42,42 @@ function SecuritySettingsPage() { onPress: item.action, shouldShowRightIcon: true, link: '', + wrapperStyle: [styles.sectionMenuItemTopDescription], })); - }, [translate, waitForNavigate]); + }, [translate, waitForNavigate, styles]); return ( - Navigation.goBack()} - shouldShowBackButton={isSmallScreenWidth} - illustration={LottieAnimations.Safe} - backgroundColor={theme.PAGE_THEMES[SCREENS.SETTINGS.SECURITY].backgroundColor} - shouldShowOfflineIndicatorInWideScreen - icon={Illustrations.LockClosed} + - - - + Navigation.goBack()} + icon={Illustrations.LockClosed} + /> + + +
+ +
-
+ ); } diff --git a/src/pages/signin/AppleSignInDesktopPage/index.js b/src/pages/signin/AppleSignInDesktopPage/index.ts similarity index 100% rename from src/pages/signin/AppleSignInDesktopPage/index.js rename to src/pages/signin/AppleSignInDesktopPage/index.ts diff --git a/src/pages/signin/AppleSignInDesktopPage/index.website.js b/src/pages/signin/AppleSignInDesktopPage/index.website.js deleted file mode 100644 index 867dfddc443d..000000000000 --- a/src/pages/signin/AppleSignInDesktopPage/index.website.js +++ /dev/null @@ -1,9 +0,0 @@ -import React from 'react'; -import ThirdPartySignInPage from '@pages/signin/ThirdPartySignInPage'; -import CONST from '@src/CONST'; - -function AppleSignInDesktopPage() { - return ; -} - -export default AppleSignInDesktopPage; diff --git a/src/pages/signin/AppleSignInDesktopPage/index.website.tsx b/src/pages/signin/AppleSignInDesktopPage/index.website.tsx new file mode 100644 index 000000000000..e23280a83132 --- /dev/null +++ b/src/pages/signin/AppleSignInDesktopPage/index.website.tsx @@ -0,0 +1,14 @@ +import React from 'react'; +import ThirdPartySignInPage from '@pages/signin/ThirdPartySignInPage'; +import CONST from '@src/CONST'; + +function AppleSignInDesktopPage() { + return ( + + ); +} + +export default AppleSignInDesktopPage; diff --git a/src/pages/signin/DesktopRedirectPage.js b/src/pages/signin/DesktopRedirectPage.tsx similarity index 95% rename from src/pages/signin/DesktopRedirectPage.js rename to src/pages/signin/DesktopRedirectPage.tsx index 1c2521be09e2..1c57df514f31 100644 --- a/src/pages/signin/DesktopRedirectPage.js +++ b/src/pages/signin/DesktopRedirectPage.tsx @@ -6,8 +6,6 @@ import * as App from '@userActions/App'; * Landing page for when a user enters third party login flow on desktop. * Allows user to open the link in browser if they accidentally canceled the auto-prompt. * Also allows them to continue to the web app if they want to use it there. - * - * @returns {React.Component} */ function DesktopRedirectPage() { return ; diff --git a/src/pages/signin/DesktopSignInRedirectPage/index.js b/src/pages/signin/DesktopSignInRedirectPage/index.ts similarity index 100% rename from src/pages/signin/DesktopSignInRedirectPage/index.js rename to src/pages/signin/DesktopSignInRedirectPage/index.ts diff --git a/src/pages/signin/DesktopSignInRedirectPage/index.website.js b/src/pages/signin/DesktopSignInRedirectPage/index.website.tsx similarity index 100% rename from src/pages/signin/DesktopSignInRedirectPage/index.website.js rename to src/pages/signin/DesktopSignInRedirectPage/index.website.tsx diff --git a/src/pages/signin/GoogleSignInDesktopPage/index.js b/src/pages/signin/GoogleSignInDesktopPage/index.ts similarity index 100% rename from src/pages/signin/GoogleSignInDesktopPage/index.js rename to src/pages/signin/GoogleSignInDesktopPage/index.ts diff --git a/src/pages/signin/GoogleSignInDesktopPage/index.website.js b/src/pages/signin/GoogleSignInDesktopPage/index.website.js deleted file mode 100644 index b9451460dfb0..000000000000 --- a/src/pages/signin/GoogleSignInDesktopPage/index.website.js +++ /dev/null @@ -1,9 +0,0 @@ -import React from 'react'; -import ThirdPartySignInPage from '@pages/signin/ThirdPartySignInPage'; -import CONST from '@src/CONST'; - -function GoogleSignInDesktopPage() { - return ; -} - -export default GoogleSignInDesktopPage; diff --git a/src/pages/signin/GoogleSignInDesktopPage/index.website.tsx b/src/pages/signin/GoogleSignInDesktopPage/index.website.tsx new file mode 100644 index 000000000000..dda1a512e37d --- /dev/null +++ b/src/pages/signin/GoogleSignInDesktopPage/index.website.tsx @@ -0,0 +1,14 @@ +import React from 'react'; +import ThirdPartySignInPage from '@pages/signin/ThirdPartySignInPage'; +import CONST from '@src/CONST'; + +function GoogleSignInDesktopPage() { + return ( + + ); +} + +export default GoogleSignInDesktopPage; diff --git a/src/pages/tasks/NewTaskPage.js b/src/pages/tasks/NewTaskPage.js index bf54d02f778f..f77285190e62 100644 --- a/src/pages/tasks/NewTaskPage.js +++ b/src/pages/tasks/NewTaskPage.js @@ -17,6 +17,7 @@ import * as LocalePhoneNumber from '@libs/LocalePhoneNumber'; import Navigation from '@libs/Navigation/Navigation'; import * as OptionsListUtils from '@libs/OptionsListUtils'; import * as ReportUtils from '@libs/ReportUtils'; +import playSound, {SOUNDS} from '@libs/Sound'; import reportPropTypes from '@pages/reportPropTypes'; import * as Task from '@userActions/Task'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -124,6 +125,7 @@ function NewTaskPage(props) { return; } + playSound(SOUNDS.DONE); Task.createTaskAndNavigate( parentReport.reportID, props.task.title, diff --git a/src/pages/wallet/WalletStatementPage.js b/src/pages/wallet/WalletStatementPage.tsx similarity index 52% rename from src/pages/wallet/WalletStatementPage.js rename to src/pages/wallet/WalletStatementPage.tsx index e97bb33519a0..ac1b6d428d52 100644 --- a/src/pages/wallet/WalletStatementPage.js +++ b/src/pages/wallet/WalletStatementPage.tsx @@ -1,61 +1,41 @@ +import type {StackScreenProps} from '@react-navigation/stack'; import {format, getMonth, getYear} from 'date-fns'; import Str from 'expensify-common/lib/str'; -import lodashGet from 'lodash/get'; -import PropTypes from 'prop-types'; import React, {useEffect} from 'react'; import {withOnyx} from 'react-native-onyx'; +import type {OnyxEntry} from 'react-native-onyx'; import FullPageOfflineBlockingView from '@components/BlockingViews/FullPageOfflineBlockingView'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; -import networkPropTypes from '@components/networkPropTypes'; -import {withNetwork} from '@components/OnyxProvider'; import ScreenWrapper from '@components/ScreenWrapper'; import WalletStatementModal from '@components/WalletStatementModal'; -import withLocalize, {withLocalizePropTypes} from '@components/withLocalize'; +import useLocalize from '@hooks/useLocalize'; +import useNetwork from '@hooks/useNetwork'; import useThemePreference from '@hooks/useThemePreference'; -import compose from '@libs/compose'; import DateUtils from '@libs/DateUtils'; import fileDownload from '@libs/fileDownload'; import Growl from '@libs/Growl'; import Navigation from '@libs/Navigation/Navigation'; +import type {WalletStatementNavigatorParamList} from '@navigation/types'; import * as User from '@userActions/User'; import CONFIG from '@src/CONFIG'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; +import type SCREENS from '@src/SCREENS'; +import type {WalletStatement} from '@src/types/onyx'; -const propTypes = { - /** The route object passed to this page from the navigator */ - route: PropTypes.shape({ - /** Each parameter passed via the URL */ - params: PropTypes.shape({ - /** The statement year and month as one string, i.e. 202110 */ - yearMonth: PropTypes.string.isRequired, - }).isRequired, - }).isRequired, - - walletStatement: PropTypes.shape({ - /** Whether we are currently generating a PDF version of the statement */ - isGenerating: PropTypes.bool, - }), - - /** Information about the network */ - network: networkPropTypes.isRequired, - - /** Indicates which locale the user currently has selected */ - preferredLocale: PropTypes.string, - - ...withLocalizePropTypes, +type WalletStatementOnyxProps = { + walletStatement: OnyxEntry; }; -const defaultProps = { - walletStatement: { - isGenerating: false, - }, - preferredLocale: CONST.LOCALES.DEFAULT, -}; +type WalletStatementPageProps = WalletStatementOnyxProps & StackScreenProps; -function WalletStatementPage(props) { +function WalletStatementPage({walletStatement, route}: WalletStatementPageProps) { const themePreference = useThemePreference(); - const yearMonth = lodashGet(props.route.params, 'yearMonth', null); + const yearMonth = route.params.yearMonth ?? null; + const isWalletStatementGenerating = walletStatement?.isGenerating ?? false; + + const {translate, preferredLocale} = useLocalize(); + const {isOffline} = useNetwork(); useEffect(() => { const currentYearMonth = format(new Date(), CONST.DATE.YEAR_MONTH_FORMAT); @@ -66,31 +46,31 @@ function WalletStatementPage(props) { }, []); useEffect(() => { - DateUtils.setLocale(props.preferredLocale); - }, [props.preferredLocale]); + DateUtils.setLocale(preferredLocale); + }, [preferredLocale]); const processDownload = () => { - if (props.walletStatement.isGenerating) { + if (isWalletStatementGenerating) { return; } - if (props.walletStatement[yearMonth]) { + if (walletStatement?.[yearMonth]) { // We already have a file URL for this statement, so we can download it immediately const downloadFileName = `Expensify_Statement_${yearMonth}.pdf`; - const fileName = props.walletStatement[yearMonth]; + const fileName = walletStatement[yearMonth]; const pdfURL = `${CONFIG.EXPENSIFY.EXPENSIFY_URL}secure?secureType=pdfreport&filename=${fileName}&downloadName=${downloadFileName}`; fileDownload(pdfURL, downloadFileName); return; } - Growl.show(props.translate('statementPage.generatingPDF'), CONST.GROWL.SUCCESS, 3000); + Growl.show(translate('statementPage.generatingPDF'), CONST.GROWL.SUCCESS, 3000); User.generateStatementPDF(yearMonth); }; - const year = yearMonth.substring(0, 4) || getYear(new Date()); - const month = yearMonth.substring(4) || getMonth(new Date()); - const monthName = format(new Date(year, month - 1), CONST.DATE.MONTH_FORMAT); - const title = props.translate('statementPage.title', year, monthName); + const year = yearMonth?.substring(0, 4) || getYear(new Date()); + const month = yearMonth?.substring(4) || getMonth(new Date()); + const monthName = format(new Date(Number(year), Number(month) - 1), CONST.DATE.MONTH_FORMAT); + const title = translate('statementPage.title', year, monthName); const url = `${CONFIG.EXPENSIFY.EXPENSIFY_URL}statement.php?period=${yearMonth}${themePreference === CONST.THEME.DARK ? '&isDarkMode=true' : ''}`; return ( @@ -101,7 +81,7 @@ function WalletStatementPage(props) { > @@ -111,19 +91,10 @@ function WalletStatementPage(props) { ); } -WalletStatementPage.propTypes = propTypes; -WalletStatementPage.defaultProps = defaultProps; WalletStatementPage.displayName = 'WalletStatementPage'; -export default compose( - withLocalize, - withOnyx({ - preferredLocale: { - key: ONYXKEYS.NVP_PREFERRED_LOCALE, - }, - walletStatement: { - key: ONYXKEYS.WALLET_STATEMENT, - }, - }), - withNetwork(), -)(WalletStatementPage); +export default withOnyx({ + walletStatement: { + key: ONYXKEYS.WALLET_STATEMENT, + }, +})(WalletStatementPage); diff --git a/src/pages/workspace/WorkspaceNewRoomPage.js b/src/pages/workspace/WorkspaceNewRoomPage.js index 5c77f3a03191..36a9647fb486 100644 --- a/src/pages/workspace/WorkspaceNewRoomPage.js +++ b/src/pages/workspace/WorkspaceNewRoomPage.js @@ -114,7 +114,7 @@ function WorkspaceNewRoomPage(props) { key: policy.id, value: policy.id, }), - ), + ).sort((a, b) => a.label.toLowerCase().localeCompare(b.label.toLowerCase())), [props.policies], ); const [policyID, setPolicyID] = useState(() => { diff --git a/src/pages/workspace/WorkspacePageWithSections.tsx b/src/pages/workspace/WorkspacePageWithSections.tsx index 70198f38f18c..09fe372bd1da 100644 --- a/src/pages/workspace/WorkspacePageWithSections.tsx +++ b/src/pages/workspace/WorkspacePageWithSections.tsx @@ -21,7 +21,6 @@ import type {Route} from '@src/ROUTES'; import ROUTES from '@src/ROUTES'; import type {Policy, ReimbursementAccount, User} from '@src/types/onyx'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; -import type {PolicyRoute} from './withPolicy'; import type {WithPolicyAndFullscreenLoadingProps} from './withPolicyAndFullscreenLoading'; import withPolicyAndFullscreenLoading from './withPolicyAndFullscreenLoading'; @@ -41,9 +40,6 @@ type WorkspacePageWithSectionsProps = WithPolicyAndFullscreenLoadingProps & /** The text to display in the header */ headerText: string; - /** The route object passed to this page from the navigator */ - route: PolicyRoute; - /** Main content of the page */ children: (hasVBA: boolean, policyID: string, isUsingECard: boolean) => ReactNode; @@ -84,7 +80,7 @@ function fetchData(skipVBBACal?: boolean) { } function WorkspacePageWithSections({ - backButtonRoute = '', + backButtonRoute, children = () => null, footer = null, guidesCallTaskID = '', diff --git a/src/pages/workspace/WorkspacesListPage.tsx b/src/pages/workspace/WorkspacesListPage.tsx index 9b763120b30d..86b3d2aae51f 100755 --- a/src/pages/workspace/WorkspacesListPage.tsx +++ b/src/pages/workspace/WorkspacesListPage.tsx @@ -6,6 +6,7 @@ import type {ValueOf} from 'type-fest'; import Button from '@components/Button'; import ConfirmModal from '@components/ConfirmModal'; import FeatureList from '@components/FeatureList'; +import type {FeatureListItem} from '@components/FeatureList'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import * as Expensicons from '@components/Icon/Expensicons'; import * as Illustrations from '@components/Icon/Illustrations'; @@ -31,7 +32,6 @@ import * as Policy from '@userActions/Policy'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; -import SCREENS from '@src/SCREENS'; import type {PolicyMembers, Policy as PolicyType, ReimbursementAccount, Report} from '@src/types/onyx'; import type * as OnyxCommon from '@src/types/onyx/OnyxCommon'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; @@ -78,7 +78,7 @@ type WorkspaceListPageOnyxProps = { type WorkspaceListPageProps = WithPolicyAndFullscreenLoadingProps & WorkspaceListPageOnyxProps; -const workspaceFeatures = [ +const workspaceFeatures: FeatureListItem[] = [ { icon: Illustrations.MoneyReceipts, translationKey: 'workspace.emptyWorkspace.features.trackAndCollect', @@ -125,7 +125,7 @@ function WorkspacesListPage({policies, allPolicyMembers, reimbursementAccount, r return; } - Policy.deleteWorkspace(policyIDToDelete, [], policyNameToDelete); + Policy.deleteWorkspace(policyIDToDelete, policyNameToDelete); setIsDeleteModalOpen(false); }; @@ -208,7 +208,7 @@ function WorkspacesListPage({policies, allPolicyMembers, reimbursementAccount, r } return ( - + App.createWorkspaceWithPolicyDraftAndNavigateToIt()} - // @ts-expect-error TODO: Remove once FeatureList (https://github.com/Expensify/App/issues/25039) is migrated to TS illustration={LottieAnimations.WorkspacePlanet} - illustrationBackgroundColor={theme.PAGE_THEMES[SCREENS.SETTINGS.WORKSPACES].backgroundColor} // We use this style to vertically center the illustration, as the original illustration is not centered illustrationStyle={styles.emptyWorkspaceIllustrationStyle} /> @@ -377,6 +375,7 @@ function WorkspacesListPage({policies, allPolicyMembers, reimbursementAccount, r data={workspaces} renderItem={getMenuItem} ListHeaderComponent={listHeaderComponent} + stickyHeaderIndices={[0]} /> - - {props.translate('workspace.card.noVBACopy')} - - - - - - ); -} - -WorkspaceCardNoVBAView.propTypes = propTypes; -WorkspaceCardNoVBAView.displayName = 'WorkspaceCardNoVBAView'; - -export default withLocalize(WorkspaceCardNoVBAView); diff --git a/src/pages/workspace/card/WorkspaceCardNoVBAView.tsx b/src/pages/workspace/card/WorkspaceCardNoVBAView.tsx new file mode 100644 index 000000000000..cd6293298830 --- /dev/null +++ b/src/pages/workspace/card/WorkspaceCardNoVBAView.tsx @@ -0,0 +1,42 @@ +import React from 'react'; +import {View} from 'react-native'; +import ConnectBankAccountButton from '@components/ConnectBankAccountButton'; +import * as Illustrations from '@components/Icon/Illustrations'; +import Section from '@components/Section'; +import Text from '@components/Text'; +import UnorderedList from '@components/UnorderedList'; +import useLocalize from '@hooks/useLocalize'; +import useThemeStyles from '@hooks/useThemeStyles'; + +type WorkspaceCardNoVBAViewProps = { + /** The policy ID currently being configured */ + policyID: string; +}; + +function WorkspaceCardNoVBAView({policyID}: WorkspaceCardNoVBAViewProps) { + const styles = useThemeStyles(); + const {translate} = useLocalize(); + const unorderedListItems = [translate('workspace.card.benefit1'), translate('workspace.card.benefit2'), translate('workspace.card.benefit3'), translate('workspace.card.benefit4')]; + + return ( +
+ + {translate('workspace.card.noVBACopy')} + + + + +
+ ); +} + +WorkspaceCardNoVBAView.displayName = 'WorkspaceCardNoVBAView'; + +export default WorkspaceCardNoVBAView; diff --git a/src/pages/workspace/card/WorkspaceCardPage.js b/src/pages/workspace/card/WorkspaceCardPage.tsx similarity index 62% rename from src/pages/workspace/card/WorkspaceCardPage.js rename to src/pages/workspace/card/WorkspaceCardPage.tsx index 90dc1662cd76..710ef3735026 100644 --- a/src/pages/workspace/card/WorkspaceCardPage.js +++ b/src/pages/workspace/card/WorkspaceCardPage.tsx @@ -1,43 +1,35 @@ -import PropTypes from 'prop-types'; +import type {StackScreenProps} from '@react-navigation/stack'; import React from 'react'; import {View} from 'react-native'; -import withLocalize, {withLocalizePropTypes} from '@components/withLocalize'; +import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; import useWindowDimensions from '@hooks/useWindowDimensions'; +import type {CentralPaneNavigatorParamList} from '@libs/Navigation/types'; import WorkspacePageWithSections from '@pages/workspace/WorkspacePageWithSections'; import CONST from '@src/CONST'; +import type SCREENS from '@src/SCREENS'; import WorkspaceCardNoVBAView from './WorkspaceCardNoVBAView'; import WorkspaceCardVBANoECardView from './WorkspaceCardVBANoECardView'; import WorkspaceCardVBAWithECardView from './WorkspaceCardVBAWithECardView'; -const propTypes = { - /** The route object passed to this page from the navigator */ - route: PropTypes.shape({ - /** Each parameter passed via the URL */ - params: PropTypes.shape({ - /** The policyID that is being configured */ - policyID: PropTypes.string.isRequired, - }).isRequired, - }).isRequired, +type WorkspaceCardPageProps = StackScreenProps; - ...withLocalizePropTypes, -}; - -function WorkspaceCardPage(props) { +function WorkspaceCardPage({route}: WorkspaceCardPageProps) { + const {translate} = useLocalize(); const styles = useThemeStyles(); const {isSmallScreenWidth} = useWindowDimensions(); return ( - {(hasVBA, policyID, isUsingECard) => ( + {(hasVBA?: boolean, policyID?: string, isUsingECard?: boolean) => ( - {!hasVBA && } + {!hasVBA && } {hasVBA && !isUsingECard && } @@ -48,7 +40,6 @@ function WorkspaceCardPage(props) { ); } -WorkspaceCardPage.propTypes = propTypes; WorkspaceCardPage.displayName = 'WorkspaceCardPage'; -export default withLocalize(WorkspaceCardPage); +export default WorkspaceCardPage; diff --git a/src/pages/workspace/card/WorkspaceCardVBANoECardView.js b/src/pages/workspace/card/WorkspaceCardVBANoECardView.tsx similarity index 52% rename from src/pages/workspace/card/WorkspaceCardVBANoECardView.js rename to src/pages/workspace/card/WorkspaceCardVBANoECardView.tsx index 7e2e5b835b74..02874a96ba76 100644 --- a/src/pages/workspace/card/WorkspaceCardVBANoECardView.js +++ b/src/pages/workspace/card/WorkspaceCardVBANoECardView.tsx @@ -1,5 +1,6 @@ import React from 'react'; import {View} from 'react-native'; +import type {OnyxEntry} from 'react-native-onyx'; import {withOnyx} from 'react-native-onyx'; import Button from '@components/Button'; import * as Expensicons from '@components/Icon/Expensicons'; @@ -7,46 +8,37 @@ import * as Illustrations from '@components/Icon/Illustrations'; import Section from '@components/Section'; import Text from '@components/Text'; import UnorderedList from '@components/UnorderedList'; -import withLocalize, {withLocalizePropTypes} from '@components/withLocalize'; +import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; -import compose from '@libs/compose'; -import userPropTypes from '@pages/settings/userPropTypes'; import * as Link from '@userActions/Link'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; +import type {User} from '@src/types/onyx'; -const propTypes = { +type WorkspaceCardVBANoECardViewOnyxProps = { /** Information about the logged in user's account */ - user: userPropTypes, - - ...withLocalizePropTypes, + user: OnyxEntry; }; -const defaultProps = { - user: {}, -}; +type WorkspaceCardVBANoECardViewProps = WorkspaceCardVBANoECardViewOnyxProps; -function WorkspaceCardVBANoECardView(props) { +function WorkspaceCardVBANoECardView({user}: WorkspaceCardVBANoECardViewProps) { const styles = useThemeStyles(); + const {translate} = useLocalize(); + const unorderedListItems = [translate('workspace.card.benefit1'), translate('workspace.card.benefit2'), translate('workspace.card.benefit3'), translate('workspace.card.benefit4')]; + return ( <>
- +
- {Boolean(props.user.isCheckingDomain) && {props.translate('workspace.card.checkingDomain')}} + {!!user?.isCheckingDomain && {translate('workspace.card.checkingDomain')}} ); } -WorkspaceCardVBANoECardView.propTypes = propTypes; -WorkspaceCardVBANoECardView.defaultProps = defaultProps; WorkspaceCardVBANoECardView.displayName = 'WorkspaceCardVBANoECardView'; -export default compose( - withLocalize, - withOnyx({ - user: { - key: ONYXKEYS.USER, - }, - }), -)(WorkspaceCardVBANoECardView); +export default withOnyx({ + user: { + key: ONYXKEYS.USER, + }, +})(WorkspaceCardVBANoECardView); diff --git a/src/pages/workspace/card/WorkspaceCardVBAWithECardView.js b/src/pages/workspace/card/WorkspaceCardVBAWithECardView.tsx similarity index 66% rename from src/pages/workspace/card/WorkspaceCardVBAWithECardView.js rename to src/pages/workspace/card/WorkspaceCardVBAWithECardView.tsx index b5a4872770a9..61c2e43f23bf 100644 --- a/src/pages/workspace/card/WorkspaceCardVBAWithECardView.js +++ b/src/pages/workspace/card/WorkspaceCardVBAWithECardView.tsx @@ -2,28 +2,29 @@ import React from 'react'; import {View} from 'react-native'; import * as Expensicons from '@components/Icon/Expensicons'; import * as Illustrations from '@components/Icon/Illustrations'; +import type {MenuItemWithLink} from '@components/MenuItemList'; import Section from '@components/Section'; import Text from '@components/Text'; import UnorderedList from '@components/UnorderedList'; -import withLocalize, {withLocalizePropTypes} from '@components/withLocalize'; +import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; import * as Link from '@userActions/Link'; -const propTypes = { - ...withLocalizePropTypes, -}; - const MENU_LINKS = { ISSUE_AND_MANAGE_CARDS: 'domain_companycards', RECONCILE_CARDS: encodeURI('domain_companycards?param={"section":"cardReconciliation"}'), SETTLEMENT_FREQUENCY: encodeURI('domain_companycards?param={"section":"configureSettings"}'), -}; +} as const; -function WorkspaceCardVBAWithECardView(props) { +function WorkspaceCardVBAWithECardView() { const styles = useThemeStyles(); - const menuItems = [ + const {translate} = useLocalize(); + + const unorderedListItems = [translate('workspace.card.benefit1'), translate('workspace.card.benefit2'), translate('workspace.card.benefit3'), translate('workspace.card.benefit4')]; + + const menuItems: MenuItemWithLink[] = [ { - title: props.translate('workspace.common.issueAndManageCards'), + title: translate('workspace.common.issueAndManageCards'), onPress: () => Link.openOldDotLink(MENU_LINKS.ISSUE_AND_MANAGE_CARDS), icon: Expensicons.ExpensifyCard, shouldShowRightIcon: true, @@ -32,7 +33,7 @@ function WorkspaceCardVBAWithECardView(props) { link: () => Link.buildOldDotURL(MENU_LINKS.ISSUE_AND_MANAGE_CARDS), }, { - title: props.translate('workspace.common.reconcileCards'), + title: translate('workspace.common.reconcileCards'), onPress: () => Link.openOldDotLink(MENU_LINKS.RECONCILE_CARDS), icon: Expensicons.ReceiptSearch, shouldShowRightIcon: true, @@ -41,7 +42,7 @@ function WorkspaceCardVBAWithECardView(props) { link: () => Link.buildOldDotURL(MENU_LINKS.RECONCILE_CARDS), }, { - title: props.translate('workspace.common.settlementFrequency'), + title: translate('workspace.common.settlementFrequency'), onPress: () => Link.openOldDotLink(MENU_LINKS.SETTLEMENT_FREQUENCY), icon: Expensicons.Gear, shouldShowRightIcon: true, @@ -53,30 +54,22 @@ function WorkspaceCardVBAWithECardView(props) { return (
- {props.translate('workspace.card.VBAWithECardCopy')} + {translate('workspace.card.VBAWithECardCopy')} - +
); } -WorkspaceCardVBAWithECardView.propTypes = propTypes; WorkspaceCardVBAWithECardView.displayName = 'WorkspaceCardVBAWithECardView'; -export default withLocalize(WorkspaceCardVBAWithECardView); +export default WorkspaceCardVBAWithECardView; diff --git a/src/pages/workspace/reimburse/WorkspaceRateAndUnitPage.js b/src/pages/workspace/reimburse/WorkspaceRateAndUnitPage.js deleted file mode 100644 index 2ed09212233c..000000000000 --- a/src/pages/workspace/reimburse/WorkspaceRateAndUnitPage.js +++ /dev/null @@ -1,172 +0,0 @@ -import lodashGet from 'lodash/get'; -import React, {useEffect} from 'react'; -import {Keyboard, View} from 'react-native'; -import {withOnyx} from 'react-native-onyx'; -import _ from 'underscore'; -import FormProvider from '@components/Form/FormProvider'; -import InputWrapper from '@components/Form/InputWrapper'; -import OfflineWithFeedback from '@components/OfflineWithFeedback'; -import {withNetwork} from '@components/OnyxProvider'; -import Picker from '@components/Picker'; -import TextInput from '@components/TextInput'; -import withLocalize, {withLocalizePropTypes} from '@components/withLocalize'; -import withThemeStyles, {withThemeStylesPropTypes} from '@components/withThemeStyles'; -import compose from '@libs/compose'; -import * as CurrencyUtils from '@libs/CurrencyUtils'; -import getPermittedDecimalSeparator from '@libs/getPermittedDecimalSeparator'; -import Navigation from '@libs/Navigation/Navigation'; -import * as NumberUtils from '@libs/NumberUtils'; -import * as PolicyUtils from '@libs/PolicyUtils'; -import withPolicy, {policyDefaultProps, policyPropTypes} from '@pages/workspace/withPolicy'; -import WorkspacePageWithSections from '@pages/workspace/WorkspacePageWithSections'; -import * as BankAccounts from '@userActions/BankAccounts'; -import * as Policy from '@userActions/Policy'; -import CONST from '@src/CONST'; -import ONYXKEYS from '@src/ONYXKEYS'; - -const propTypes = { - ...policyPropTypes, - ...withLocalizePropTypes, - ...withThemeStylesPropTypes, -}; - -const defaultProps = { - reimbursementAccount: {}, - ...policyDefaultProps, -}; - -function WorkspaceRateAndUnitPage(props) { - useEffect(() => { - if (lodashGet(props, 'policy.customUnits', []).length !== 0) { - return; - } - - BankAccounts.setReimbursementAccountLoading(true); - Policy.openWorkspaceReimburseView(props.policy.id); - }, [props]); - - const unitItems = [ - {label: props.translate('common.kilometers'), value: CONST.CUSTOM_UNITS.DISTANCE_UNIT_KILOMETERS}, - {label: props.translate('common.miles'), value: CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES}, - ]; - - const saveUnitAndRate = (unit, rate) => { - const distanceCustomUnit = _.find(lodashGet(props, 'policy.customUnits', {}), (customUnit) => customUnit.name === CONST.CUSTOM_UNITS.NAME_DISTANCE); - if (!distanceCustomUnit) { - return; - } - const currentCustomUnitRate = _.find(lodashGet(distanceCustomUnit, 'rates', {}), (r) => r.name === CONST.CUSTOM_UNITS.DEFAULT_RATE); - const unitID = lodashGet(distanceCustomUnit, 'customUnitID', ''); - const unitName = lodashGet(distanceCustomUnit, 'name', ''); - const rateNumValue = PolicyUtils.getNumericValue(rate, props.toLocaleDigit); - - const newCustomUnit = { - customUnitID: unitID, - name: unitName, - attributes: {unit}, - rates: { - ...currentCustomUnitRate, - rate: rateNumValue * CONST.POLICY.CUSTOM_UNIT_RATE_BASE_OFFSET, - }, - }; - Policy.updateWorkspaceCustomUnitAndRate(props.policy.id, distanceCustomUnit, newCustomUnit, props.policy.lastModified); - }; - - const submit = (values) => { - saveUnitAndRate(values.unit, values.rate); - Keyboard.dismiss(); - Navigation.goBack(); - }; - - const validate = (values) => { - const errors = {}; - const decimalSeparator = props.toLocaleDigit('.'); - const outputCurrency = lodashGet(props, 'policy.outputCurrency', CONST.CURRENCY.USD); - // Allow one more decimal place for accuracy - const rateValueRegex = RegExp(String.raw`^-?\d{0,8}([${getPermittedDecimalSeparator(decimalSeparator)}]\d{1,${CurrencyUtils.getCurrencyDecimals(outputCurrency) + 1}})?$`, 'i'); - if (!rateValueRegex.test(values.rate) || values.rate === '') { - errors.rate = 'workspace.reimburse.invalidRateError'; - } else if (NumberUtils.parseFloatAnyLocale(values.rate) <= 0) { - errors.rate = 'workspace.reimburse.lowRateError'; - } - return errors; - }; - - const distanceCustomUnit = _.find(lodashGet(props, 'policy.customUnits', {}), (unit) => unit.name === CONST.CUSTOM_UNITS.NAME_DISTANCE); - const distanceCustomRate = _.find(lodashGet(distanceCustomUnit, 'rates', {}), (rate) => rate.name === CONST.CUSTOM_UNITS.DEFAULT_RATE); - - return ( - - {() => ( - - - Policy.clearCustomUnitErrors(props.policy.id, lodashGet(distanceCustomUnit, 'customUnitID', ''), lodashGet(distanceCustomRate, 'customUnitRateID', '')) - } - > - - - - - - - - )} - - ); -} - -WorkspaceRateAndUnitPage.propTypes = propTypes; -WorkspaceRateAndUnitPage.defaultProps = defaultProps; -WorkspaceRateAndUnitPage.displayName = 'WorkspaceRateAndUnitPage'; - -export default compose( - withPolicy, - withLocalize, - withNetwork(), - withOnyx({ - reimbursementAccount: { - key: ONYXKEYS.REIMBURSEMENT_ACCOUNT, - }, - }), - withThemeStyles, -)(WorkspaceRateAndUnitPage); diff --git a/src/pages/workspace/reimburse/WorkspaceRateAndUnitPage/InitialPage.tsx b/src/pages/workspace/reimburse/WorkspaceRateAndUnitPage/InitialPage.tsx new file mode 100644 index 000000000000..1cafc20e5c3f --- /dev/null +++ b/src/pages/workspace/reimburse/WorkspaceRateAndUnitPage/InitialPage.tsx @@ -0,0 +1,175 @@ +import React, {useEffect, useMemo} from 'react'; +import {ScrollView, View} from 'react-native'; +import {withOnyx} from 'react-native-onyx'; +import type {OnyxEntry} from 'react-native-onyx'; +import FormAlertWithSubmitButton from '@components/FormAlertWithSubmitButton'; +import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; +import OfflineWithFeedback from '@components/OfflineWithFeedback'; +import {withNetwork} from '@components/OnyxProvider'; +import useLocalize from '@hooks/useLocalize'; +import useThemeStyles from '@hooks/useThemeStyles'; +import compose from '@libs/compose'; +import * as CurrencyUtils from '@libs/CurrencyUtils'; +import Navigation from '@libs/Navigation/Navigation'; +import withPolicy from '@pages/workspace/withPolicy'; +import type {WithPolicyProps} from '@pages/workspace/withPolicy'; +import WorkspacePageWithSections from '@pages/workspace/WorkspacePageWithSections'; +import * as BankAccounts from '@userActions/BankAccounts'; +import * as Policy from '@userActions/Policy'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import ROUTES from '@src/ROUTES'; +import type {Network, ReimbursementAccount, WorkspaceRateAndUnit} from '@src/types/onyx'; +import type {Unit} from '@src/types/onyx/Policy'; +import {isEmptyObject} from '@src/types/utils/EmptyObject'; + +type WorkspaceRateAndUnitPageBaseProps = WithPolicyProps & { + // eslint-disable-next-line react/no-unused-prop-types + network: OnyxEntry; +}; + +type WorkspaceRateAndUnitOnyxProps = { + workspaceRateAndUnit: OnyxEntry; + // eslint-disable-next-line react/no-unused-prop-types + reimbursementAccount: OnyxEntry; +}; + +type WorkspaceRateAndUnitPageProps = WorkspaceRateAndUnitPageBaseProps & WorkspaceRateAndUnitOnyxProps; + +function WorkspaceRateAndUnitPage(props: WorkspaceRateAndUnitPageProps) { + const styles = useThemeStyles(); + const {translate} = useLocalize(); + + useEffect(() => { + if (props.workspaceRateAndUnit?.policyID === props.policy?.id) { + return; + } + Policy.setPolicyIDForReimburseView(props.policy?.id ?? ''); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + useEffect(() => { + const customUnits = props.policy?.customUnits ?? {}; + if (!isEmptyObject(customUnits)) { + return; + } + + BankAccounts.setReimbursementAccountLoading(true); + Policy.openWorkspaceReimburseView(props.policy?.id ?? ''); + }, [props]); + + const unitItems = useMemo( + () => ({ + [CONST.CUSTOM_UNITS.DISTANCE_UNIT_KILOMETERS]: translate('workspace.reimburse.kilometers'), + [CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES]: translate('workspace.reimburse.miles'), + }), + [translate], + ); + + const saveUnitAndRate = (newUnit: Unit, newRate: string) => { + const distanceCustomUnit = Object.values(props.policy?.customUnits ?? {}).find((unit) => unit.name === CONST.CUSTOM_UNITS.NAME_DISTANCE); + if (!distanceCustomUnit) { + return; + } + const currentCustomUnitRate = Object.values(distanceCustomUnit?.rates ?? {}).find((rate) => rate.name === CONST.CUSTOM_UNITS.DEFAULT_RATE); + const unitID = distanceCustomUnit.customUnitID ?? ''; + const unitName = distanceCustomUnit.name ?? ''; + + const newCustomUnit = { + customUnitID: unitID, + name: unitName, + attributes: {unit: newUnit}, + rates: { + ...currentCustomUnitRate, + rate: parseFloat(newRate), + }, + }; + Policy.updateWorkspaceCustomUnitAndRate(props.policy?.id ?? '', distanceCustomUnit, newCustomUnit, props.policy?.lastModified); + }; + + const distanceCustomUnit = Object.values(props.policy?.customUnits ?? {}).find((unit) => unit.name === CONST.CUSTOM_UNITS.NAME_DISTANCE); + const distanceCustomRate = Object.values(distanceCustomUnit?.rates ?? {}).find((rate) => rate.name === CONST.CUSTOM_UNITS.DEFAULT_RATE); + + const unitValue = props.workspaceRateAndUnit?.unit ?? distanceCustomUnit?.attributes.unit ?? CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES; + const rateValue = props.workspaceRateAndUnit?.rate ?? distanceCustomRate?.rate?.toString() ?? ''; + + const submit = () => { + saveUnitAndRate(unitValue, rateValue); + Policy.clearOnyxDataForReimburseView(); + Navigation.goBack(); + }; + + return ( + + {() => ( + + + + Policy.clearCustomUnitErrors(props.policy?.id ?? '', distanceCustomUnit?.customUnitID ?? '', distanceCustomRate?.customUnitRateID ?? '')} + > + Navigation.navigate(ROUTES.WORKSPACE_RATE_AND_UNIT_RATE.getRoute(props.policy?.id ?? ''))} + shouldShowRightIcon + /> + Navigation.navigate(ROUTES.WORKSPACE_RATE_AND_UNIT_UNIT.getRoute(props.policy?.id ?? ''))} + shouldShowRightIcon + /> + + + + + submit()} + enabledWhenOffline + buttonText={translate('common.save')} + containerStyles={[styles.mh0, styles.mt5, styles.flex1, styles.ph5]} + isAlertVisible={false} + /> + + + )} + + ); +} + +WorkspaceRateAndUnitPage.displayName = 'WorkspaceRateAndUnitPage'; + +export default compose( + withOnyx({ + reimbursementAccount: { + key: ONYXKEYS.REIMBURSEMENT_ACCOUNT, + }, + workspaceRateAndUnit: { + key: ONYXKEYS.WORKSPACE_RATE_AND_UNIT, + }, + }), + withPolicy, + withNetwork(), +)(WorkspaceRateAndUnitPage); diff --git a/src/pages/workspace/reimburse/WorkspaceRateAndUnitPage/RatePage.tsx b/src/pages/workspace/reimburse/WorkspaceRateAndUnitPage/RatePage.tsx new file mode 100644 index 000000000000..37723fe654c0 --- /dev/null +++ b/src/pages/workspace/reimburse/WorkspaceRateAndUnitPage/RatePage.tsx @@ -0,0 +1,119 @@ +import React, {useEffect, useMemo} from 'react'; +import {withOnyx} from 'react-native-onyx'; +import type {OnyxEntry} from 'react-native-onyx'; +import AmountForm from '@components/AmountForm'; +import FormProvider from '@components/Form/FormProvider'; +import InputWrapperWithRef from '@components/Form/InputWrapper'; +import type {OnyxFormValuesFields} from '@components/Form/types'; +import useLocalize from '@hooks/useLocalize'; +import useThemeStyles from '@hooks/useThemeStyles'; +import compose from '@libs/compose'; +import * as CurrencyUtils from '@libs/CurrencyUtils'; +import getPermittedDecimalSeparator from '@libs/getPermittedDecimalSeparator'; +import * as MoneyRequestUtils from '@libs/MoneyRequestUtils'; +import Navigation from '@libs/Navigation/Navigation'; +import * as NumberUtils from '@libs/NumberUtils'; +import withPolicy from '@pages/workspace/withPolicy'; +import type {WithPolicyProps} from '@pages/workspace/withPolicy'; +import WorkspacePageWithSections from '@pages/workspace/WorkspacePageWithSections'; +import * as Policy from '@userActions/Policy'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import ROUTES from '@src/ROUTES'; +import type {WorkspaceRateAndUnit} from '@src/types/onyx'; + +type WorkspaceRatePageBaseProps = WithPolicyProps; + +type WorkspaceRateAndUnitOnyxProps = { + workspaceRateAndUnit: OnyxEntry; +}; + +type WorkspaceRatePageProps = WorkspaceRatePageBaseProps & WorkspaceRateAndUnitOnyxProps; + +function WorkspaceRatePage(props: WorkspaceRatePageProps) { + const styles = useThemeStyles(); + const {translate, toLocaleDigit} = useLocalize(); + + useEffect(() => { + if (props.workspaceRateAndUnit?.policyID === props.policy?.id) { + return; + } + Policy.setPolicyIDForReimburseView(props.policy?.id ?? ''); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const submit = (values: OnyxFormValuesFields) => { + const rate = values.rate as string; + Policy.setRateForReimburseView((parseFloat(rate) * CONST.POLICY.CUSTOM_UNIT_RATE_BASE_OFFSET).toFixed(1)); + Navigation.navigate(ROUTES.WORKSPACE_RATE_AND_UNIT.getRoute(props.policy?.id ?? '')); + }; + + const validate = (values: OnyxFormValuesFields) => { + const errors: {rate?: string} = {}; + const rate = values.rate as string; + const parsedRate = MoneyRequestUtils.replaceAllDigits(rate, toLocaleDigit); + const decimalSeparator = toLocaleDigit('.'); + const outputCurrency = props.policy?.outputCurrency ?? CONST.CURRENCY.USD; + // Allow one more decimal place for accuracy + const rateValueRegex = RegExp(String.raw`^-?\d{0,8}([${getPermittedDecimalSeparator(decimalSeparator)}]\d{1,${CurrencyUtils.getCurrencyDecimals(outputCurrency) + 1}})?$`, 'i'); + if (!rateValueRegex.test(parsedRate) || parsedRate === '') { + errors.rate = 'workspace.reimburse.invalidRateError'; + } else if (NumberUtils.parseFloatAnyLocale(parsedRate) <= 0) { + errors.rate = 'workspace.reimburse.lowRateError'; + } + return errors; + }; + + const defaultValue = useMemo(() => { + const defaultDistanceCustomUnit = Object.values(props.policy?.customUnits ?? {}).find((unit) => unit.name === CONST.CUSTOM_UNITS.NAME_DISTANCE); + const distanceCustomRate = Object.values(defaultDistanceCustomUnit?.rates ?? {}).find((rate) => rate.name === CONST.CUSTOM_UNITS.DEFAULT_RATE); + return distanceCustomRate?.rate ?? 0; + }, [props.policy?.customUnits]); + + return ( + + {() => ( + + + + )} + + ); +} + +WorkspaceRatePage.displayName = 'WorkspaceRatePage'; + +export default compose( + withOnyx({ + workspaceRateAndUnit: { + key: ONYXKEYS.WORKSPACE_RATE_AND_UNIT, + }, + }), + withPolicy, +)(WorkspaceRatePage); diff --git a/src/pages/workspace/reimburse/WorkspaceRateAndUnitPage/UnitPage.tsx b/src/pages/workspace/reimburse/WorkspaceRateAndUnitPage/UnitPage.tsx new file mode 100644 index 000000000000..07f31d53193e --- /dev/null +++ b/src/pages/workspace/reimburse/WorkspaceRateAndUnitPage/UnitPage.tsx @@ -0,0 +1,111 @@ +import React, {useEffect, useMemo} from 'react'; +import {withOnyx} from 'react-native-onyx'; +import type {OnyxEntry} from 'react-native-onyx'; +import SelectionList from '@components/SelectionList'; +import Text from '@components/Text'; +import useLocalize from '@hooks/useLocalize'; +import useThemeStyles from '@hooks/useThemeStyles'; +import compose from '@libs/compose'; +import Navigation from '@libs/Navigation/Navigation'; +import withPolicy from '@pages/workspace/withPolicy'; +import type {WithPolicyProps} from '@pages/workspace/withPolicy'; +import WorkspacePageWithSections from '@pages/workspace/WorkspacePageWithSections'; +import * as Policy from '@userActions/Policy'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import ROUTES from '@src/ROUTES'; +import type {WorkspaceRateAndUnit} from '@src/types/onyx'; +import type {Unit} from '@src/types/onyx/Policy'; + +type OptionRow = { + value: Unit; + text: string; + keyForList: string; + isSelected: boolean; +}; + +type WorkspaceUnitPageBaseProps = WithPolicyProps; + +type WorkspaceRateAndUnitOnyxProps = { + workspaceRateAndUnit: OnyxEntry; +}; + +type WorkspaceUnitPageProps = WorkspaceUnitPageBaseProps & WorkspaceRateAndUnitOnyxProps; +function WorkspaceUnitPage(props: WorkspaceUnitPageProps) { + const styles = useThemeStyles(); + const {translate} = useLocalize(); + const unitItems = useMemo( + () => ({ + [CONST.CUSTOM_UNITS.DISTANCE_UNIT_KILOMETERS]: translate('workspace.reimburse.kilometers'), + [CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES]: translate('workspace.reimburse.miles'), + }), + [translate], + ); + + useEffect(() => { + if (props.workspaceRateAndUnit?.policyID === props.policy?.id) { + return; + } + Policy.setPolicyIDForReimburseView(props.policy?.id ?? ''); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const updateUnit = (unit: Unit) => { + Policy.setUnitForReimburseView(unit); + Navigation.navigate(ROUTES.WORKSPACE_RATE_AND_UNIT.getRoute(props.policy?.id ?? '')); + }; + + const defaultValue = useMemo(() => { + const defaultDistanceCustomUnit = Object.values(props.policy?.customUnits ?? {}).find((unit) => unit.name === CONST.CUSTOM_UNITS.NAME_DISTANCE); + return defaultDistanceCustomUnit?.attributes.unit ?? CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES; + }, [props.policy?.customUnits]); + + const unitOptions = useMemo(() => { + const arr: OptionRow[] = []; + Object.entries(unitItems).forEach(([unit, label]) => { + arr.push({ + value: unit as Unit, + text: label, + keyForList: unit, + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + isSelected: (props.workspaceRateAndUnit?.unit || defaultValue) === unit, + }); + }); + return arr; + }, [defaultValue, props.workspaceRateAndUnit?.unit, unitItems]); + + return ( + + {() => ( + <> + {translate('workspace.reimburse.trackDistanceChooseUnit')} + + updateUnit(unit.value)} + initiallyFocusedOptionKey={unitOptions.find((unit) => unit.isSelected)?.keyForList} + /> + + )} + + ); +} + +WorkspaceUnitPage.displayName = 'WorkspaceUnitPage'; + +export default compose( + withOnyx({ + workspaceRateAndUnit: { + key: ONYXKEYS.WORKSPACE_RATE_AND_UNIT, + }, + }), + withPolicy, +)(WorkspaceUnitPage); diff --git a/src/pages/workspace/reimburse/WorkspaceReimburseView.js b/src/pages/workspace/reimburse/WorkspaceReimburseView.js index 8749ea53bfc5..636675098d23 100644 --- a/src/pages/workspace/reimburse/WorkspaceReimburseView.js +++ b/src/pages/workspace/reimburse/WorkspaceReimburseView.js @@ -150,7 +150,10 @@ function WorkspaceReimburseView(props) { title={currentRatePerUnit} description={translate('workspace.reimburse.trackDistanceRate')} shouldShowRightIcon - onPress={() => Navigation.navigate(ROUTES.WORKSPACE_RATE_AND_UNIT.getRoute(props.policy.id))} + onPress={() => { + Policy.setPolicyIDForReimburseView(props.policy.id); + Navigation.navigate(ROUTES.WORKSPACE_RATE_AND_UNIT.getRoute(props.policy.id)); + }} wrapperStyle={[styles.sectionMenuItemTopDescription, styles.mt3]} brickRoadIndicator={(lodashGet(distanceCustomUnit, 'errors') || lodashGet(distanceCustomRate, 'errors')) && CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR} /> diff --git a/src/styles/index.ts b/src/styles/index.ts index 1bea3d298cd4..1d3e323d6bd1 100644 --- a/src/styles/index.ts +++ b/src/styles/index.ts @@ -443,8 +443,12 @@ const styles = (theme: ThemeColors) => color: theme.link, }, - textIvoryLight: { + textVersion: { color: theme.iconColorfulBackground, + fontSize: variables.fontSizeNormal, + lineHeight: variables.lineHeightNormal, + fontFamily: FontUtils.fontFamily.platform.MONOSPACE, + textAlign: 'center', }, textNoWrap: { @@ -533,6 +537,14 @@ const styles = (theme: ThemeColors) => paddingBottom: 1, }, + testRowContainer: { + ...flex.flexRow, + ...flex.justifyContentBetween, + ...flex.alignItemsCenter, + ...sizing.mnw120, + height: 64, + }, + buttonSmall: { borderRadius: variables.buttonBorderRadius, minHeight: variables.componentSizeSmall, @@ -1364,11 +1376,10 @@ const styles = (theme: ThemeColors) => }, sidebarFooter: { - alignItems: 'center', display: 'flex', justifyContent: 'center', - paddingVertical: variables.lineHeightXLarge, width: '100%', + paddingLeft: 20, }, sidebarAvatar: { diff --git a/src/styles/theme/themes/dark.ts b/src/styles/theme/themes/dark.ts index 8cb1566ec274..1230c4ae03c2 100644 --- a/src/styles/theme/themes/dark.ts +++ b/src/styles/theme/themes/dark.ts @@ -21,7 +21,7 @@ const darkTheme = { textSupporting: colors.productDark800, text: colors.productDark900, textColorfulBackground: colors.ivory, - syntax: colors.productDark600, + syntax: colors.productDark800, link: colors.blue300, linkHover: colors.blue100, buttonDefaultBG: colors.productDark400, @@ -105,7 +105,7 @@ const darkTheme = { statusBarStyle: CONST.STATUS_BAR_STYLE.LIGHT_CONTENT, }, [SCREENS.SETTINGS.PREFERENCES.ROOT]: { - backgroundColor: colors.blue500, + backgroundColor: colors.productDark100, statusBarStyle: CONST.STATUS_BAR_STYLE.LIGHT_CONTENT, }, [SCREENS.SETTINGS.WORKSPACES]: { @@ -116,10 +116,6 @@ const darkTheme = { backgroundColor: colors.productDark100, statusBarStyle: CONST.STATUS_BAR_STYLE.LIGHT_CONTENT, }, - [SCREENS.SETTINGS.SECURITY]: { - backgroundColor: colors.ice500, - statusBarStyle: CONST.STATUS_BAR_STYLE.DARK_CONTENT, - }, [SCREENS.SETTINGS.PROFILE.STATUS]: { backgroundColor: colors.productDark100, statusBarStyle: CONST.STATUS_BAR_STYLE.LIGHT_CONTENT, @@ -132,10 +128,6 @@ const darkTheme = { backgroundColor: colors.productDark100, statusBarStyle: CONST.STATUS_BAR_STYLE.LIGHT_CONTENT, }, - [SCREENS.SETTINGS.ABOUT]: { - backgroundColor: colors.yellow600, - statusBarStyle: CONST.STATUS_BAR_STYLE.LIGHT_CONTENT, - }, [SCREENS.REFERRAL_DETAILS]: { backgroundColor: colors.pink800, statusBarStyle: CONST.STATUS_BAR_STYLE.LIGHT_CONTENT, diff --git a/src/styles/theme/themes/light.ts b/src/styles/theme/themes/light.ts index 62c788abbfb1..ad0bdfb296ce 100644 --- a/src/styles/theme/themes/light.ts +++ b/src/styles/theme/themes/light.ts @@ -21,7 +21,7 @@ const lightTheme = { textSupporting: colors.productLight800, text: colors.productLight900, textColorfulBackground: colors.ivory, - syntax: colors.productLight600, + syntax: colors.productLight800, link: colors.blue600, linkHover: colors.blue500, buttonDefaultBG: colors.productLight400, @@ -105,7 +105,7 @@ const lightTheme = { statusBarStyle: CONST.STATUS_BAR_STYLE.LIGHT_CONTENT, }, [SCREENS.SETTINGS.PREFERENCES.ROOT]: { - backgroundColor: colors.blue500, + backgroundColor: colors.productLight100, statusBarStyle: CONST.STATUS_BAR_STYLE.LIGHT_CONTENT, }, [SCREENS.SETTINGS.WORKSPACES]: { @@ -116,10 +116,6 @@ const lightTheme = { backgroundColor: colors.productLight100, statusBarStyle: CONST.STATUS_BAR_STYLE.DARK_CONTENT, }, - [SCREENS.SETTINGS.SECURITY]: { - backgroundColor: colors.ice500, - statusBarStyle: CONST.STATUS_BAR_STYLE.DARK_CONTENT, - }, [SCREENS.SETTINGS.PROFILE.STATUS]: { backgroundColor: colors.productLight100, statusBarStyle: CONST.STATUS_BAR_STYLE.DARK_CONTENT, @@ -132,10 +128,6 @@ const lightTheme = { backgroundColor: colors.productLight100, statusBarStyle: CONST.STATUS_BAR_STYLE.DARK_CONTENT, }, - [SCREENS.SETTINGS.ABOUT]: { - backgroundColor: colors.yellow600, - statusBarStyle: CONST.STATUS_BAR_STYLE.LIGHT_CONTENT, - }, [SCREENS.REFERRAL_DETAILS]: { backgroundColor: colors.pink800, statusBarStyle: CONST.STATUS_BAR_STYLE.LIGHT_CONTENT, diff --git a/src/styles/utils/FontUtils/fontFamily/multiFontFamily.ts b/src/styles/utils/FontUtils/fontFamily/multiFontFamily.ts index 902b94021b32..4cdeac2d9570 100644 --- a/src/styles/utils/FontUtils/fontFamily/multiFontFamily.ts +++ b/src/styles/utils/FontUtils/fontFamily/multiFontFamily.ts @@ -8,7 +8,7 @@ import type FontFamilyStyles from './types'; const fontFamily: FontFamilyStyles = { EXP_NEUE_ITALIC: 'ExpensifyNeue-Italic, Segoe UI Emoji, Noto Color Emoji', EXP_NEUE_BOLD: multiBold, - EXP_NEUE: 'ExpensifyNeue-Regular, Segoe UI Emoji, Noto Color Emoji', + EXP_NEUE: 'ExpensifyNeue-Regular, Segoe UI Emoji', EXP_NEW_KANSAS_MEDIUM: 'ExpensifyNewKansas-Medium, Segoe UI Emoji, Noto Color Emoji', EXP_NEW_KANSAS_MEDIUM_ITALIC: 'ExpensifyNewKansas-MediumItalic, Segoe UI Emoji, Noto Color Emoji', SYSTEM: 'System', @@ -20,7 +20,7 @@ const fontFamily: FontFamilyStyles = { if (getOperatingSystem() === CONST.OS.WINDOWS) { Object.keys(fontFamily).forEach((key) => { - fontFamily[key as keyof FontFamilyStyles] = fontFamily[key as keyof FontFamilyStyles].replace('Segoe UI Emoji', 'Windows Segoe UI Emoji'); + fontFamily[key as keyof FontFamilyStyles] = fontFamily[key as keyof FontFamilyStyles].replace('Segoe UI Emoji', 'Noto Color Emoji'); }); } diff --git a/src/styles/utils/pointerEventsNone/index.native.ts b/src/styles/utils/pointerEventsNone/index.native.ts deleted file mode 100644 index 1503fd9f68f0..000000000000 --- a/src/styles/utils/pointerEventsNone/index.native.ts +++ /dev/null @@ -1,5 +0,0 @@ -import type PointerEventsNone from './types'; - -const pointerEventsNone: PointerEventsNone = {}; - -export default pointerEventsNone; diff --git a/src/styles/variables.ts b/src/styles/variables.ts index c6d2bebe1417..6f7c18ea0dcf 100644 --- a/src/styles/variables.ts +++ b/src/styles/variables.ts @@ -192,7 +192,7 @@ export default { workspaceSectionMaxWidth: 680, oldDotWireframeIconWidth: 263.38, oldDotWireframeIconHeight: 143.28, - sectionIllustrationHeight: 240, + sectionIllustrationHeight: 220, photoUploadPopoverWidth: 335, // The height of the empty list is 14px (2px for borders and 12px for vertical padding) diff --git a/src/types/modules/react-native-key-command.d.ts b/src/types/modules/react-native-key-command.d.ts index 4c7f07bd6d7e..d7f9641c5033 100644 --- a/src/types/modules/react-native-key-command.d.ts +++ b/src/types/modules/react-native-key-command.d.ts @@ -26,7 +26,10 @@ declare module 'react-native-key-command' { type KeyCommand = {input: string; modifierFlags?: string}; declare function addListener(keyCommand: KeyCommand, callback: (keycommandEvent: KeyCommand, event: KeyboardEvent) => void): () => void; + declare function registerKeyCommands(): void; + declare function unregisterKeyCommands(): void; + declare function eventEmitter(): void; // eslint-disable-next-line import/prefer-default-export - export {constants, addListener}; + export {constants, addListener, registerKeyCommands, unregisterKeyCommands, eventEmitter}; } diff --git a/src/types/onyx/OnyxUpdatesFromServer.ts b/src/types/onyx/OnyxUpdatesFromServer.ts index b3932bbb7841..f6104ed470a0 100644 --- a/src/types/onyx/OnyxUpdatesFromServer.ts +++ b/src/types/onyx/OnyxUpdatesFromServer.ts @@ -2,7 +2,10 @@ import type {OnyxUpdate} from 'react-native-onyx'; import type Request from './Request'; import type Response from './Response'; -type OnyxServerUpdate = OnyxUpdate & {shouldNotify?: boolean}; +type OnyxServerUpdate = OnyxUpdate & { + shouldNotify?: boolean; + shouldShowPushNotification?: boolean; +}; type OnyxUpdateEvent = { eventType: string; diff --git a/src/types/onyx/Policy.ts b/src/types/onyx/Policy.ts index d5ed5dd36aba..afdc236ca043 100644 --- a/src/types/onyx/Policy.ts +++ b/src/types/onyx/Policy.ts @@ -10,7 +10,7 @@ type Rate = { currency?: string; customUnitRateID?: string; errors?: OnyxCommon.Errors; - pendingAction?: string; + pendingAction?: OnyxCommon.PendingAction; }; type Attributes = { @@ -22,7 +22,7 @@ type CustomUnit = { customUnitID: string; attributes: Attributes; rates: Record; - pendingAction?: string; + pendingAction?: OnyxCommon.PendingAction; errors?: OnyxCommon.Errors; }; @@ -177,4 +177,4 @@ type Policy = { export default Policy; -export type {Unit, CustomUnit}; +export type {Unit, CustomUnit, Attributes, Rate}; diff --git a/src/types/onyx/TransactionViolation.ts b/src/types/onyx/TransactionViolation.ts index e7be24d5cb18..b1764b4aeb80 100644 --- a/src/types/onyx/TransactionViolation.ts +++ b/src/types/onyx/TransactionViolation.ts @@ -13,12 +13,11 @@ type TransactionViolation = { data?: { rejectedBy?: string; rejectReason?: string; - amount?: string; + formattedLimit?: string; surcharge?: number; invoiceMarkup?: number; maxAge?: number; tagName?: string; - formattedLimitAmount?: string; categoryLimit?: string; limit?: string; category?: string; diff --git a/src/types/onyx/User.ts b/src/types/onyx/User.ts index f770b22b2272..973f09e16b82 100644 --- a/src/types/onyx/User.ts +++ b/src/types/onyx/User.ts @@ -5,6 +5,9 @@ type User = { /** Whether we should use the staging version of the secure API server */ shouldUseStagingServer?: boolean; + /** Whether user muted all sounds in application */ + isMutedAllSounds?: boolean; + /** Is the user account validated? */ validated: boolean; diff --git a/src/types/onyx/WalletStatement.ts b/src/types/onyx/WalletStatement.ts index d42aae32a823..62b8266c8e43 100644 --- a/src/types/onyx/WalletStatement.ts +++ b/src/types/onyx/WalletStatement.ts @@ -1,6 +1,11 @@ type WalletStatement = { /** Whether we are currently generating a PDF version of the statement */ isGenerating: boolean; + + /** + yearMonth - key with filename as value, boolean value added for isGenerating key + */ + [yearMonth: string]: string | boolean; }; export default WalletStatement; diff --git a/src/types/onyx/WorkspaceRateAndUnit.ts b/src/types/onyx/WorkspaceRateAndUnit.ts new file mode 100644 index 000000000000..a374239c93f8 --- /dev/null +++ b/src/types/onyx/WorkspaceRateAndUnit.ts @@ -0,0 +1,14 @@ +type Unit = 'mi' | 'km'; + +type WorkspaceRateAndUnit = { + /** policyID of the Workspace */ + policyID: string; + + /** Unit of the Workspace */ + unit?: Unit; + + /** Unit of the Workspace */ + rate?: string; +}; + +export default WorkspaceRateAndUnit; diff --git a/src/types/onyx/index.ts b/src/types/onyx/index.ts index aae3b6f2532a..e7f4422c9fed 100644 --- a/src/types/onyx/index.ts +++ b/src/types/onyx/index.ts @@ -84,6 +84,7 @@ import type WalletOnfido from './WalletOnfido'; import type WalletStatement from './WalletStatement'; import type WalletTerms from './WalletTerms'; import type WalletTransfer from './WalletTransfer'; +import type WorkspaceRateAndUnit from './WorkspaceRateAndUnit'; export type { Account, @@ -163,6 +164,7 @@ export type { WalletStatement, WalletTerms, WalletTransfer, + WorkspaceRateAndUnit, WorkspaceSettingsForm, ReportUserIsTyping, PolicyReportField, diff --git a/tests/perf-test/SignInPage.perf-test.tsx b/tests/perf-test/SignInPage.perf-test.tsx index dde8596fb2ae..88a653b1fbb9 100644 --- a/tests/perf-test/SignInPage.perf-test.tsx +++ b/tests/perf-test/SignInPage.perf-test.tsx @@ -35,6 +35,8 @@ jest.mock('@react-navigation/native', () => { createNavigationContainerRef: () => ({ addListener: () => jest.fn(), removeListener: () => jest.fn(), + isReady: () => jest.fn(), + getCurrentRoute: () => jest.fn(), }), } as typeof NativeNavigation; }); diff --git a/tests/unit/ErrorUtilsTest.js b/tests/unit/ErrorUtilsTest.ts similarity index 81% rename from tests/unit/ErrorUtilsTest.js rename to tests/unit/ErrorUtilsTest.ts index 7a46c71a1aa1..9168c1ca12a5 100644 --- a/tests/unit/ErrorUtilsTest.js +++ b/tests/unit/ErrorUtilsTest.ts @@ -1,50 +1,51 @@ -import * as ErrorUtils from '../../src/libs/ErrorUtils'; +import * as ErrorUtils from '@src/libs/ErrorUtils'; +import type {Errors} from '@src/types/onyx/OnyxCommon'; describe('ErrorUtils', () => { test('should add a new error message for a given inputID', () => { - const errors = {}; + const errors: Errors = {}; ErrorUtils.addErrorMessage(errors, 'username', 'Username cannot be empty'); expect(errors).toEqual({username: ['Username cannot be empty', {isTranslated: true}]}); }); test('should append an error message to an existing error message for a given inputID', () => { - const errors = {username: 'Username cannot be empty'}; + const errors: Errors = {username: 'Username cannot be empty'}; ErrorUtils.addErrorMessage(errors, 'username', 'Username must be at least 6 characters long'); expect(errors).toEqual({username: ['Username cannot be empty\nUsername must be at least 6 characters long', {isTranslated: true}]}); }); test('should add an error to input which does not contain any errors yet', () => { - const errors = {username: 'Username cannot be empty'}; + const errors: Errors = {username: 'Username cannot be empty'}; ErrorUtils.addErrorMessage(errors, 'password', 'Password cannot be empty'); expect(errors).toEqual({username: 'Username cannot be empty', password: ['Password cannot be empty', {isTranslated: true}]}); }); test('should not mutate the errors object when message is empty', () => { - const errors = {username: 'Username cannot be empty'}; + const errors: Errors = {username: 'Username cannot be empty'}; ErrorUtils.addErrorMessage(errors, 'username', ''); expect(errors).toEqual({username: 'Username cannot be empty'}); }); test('should not mutate the errors object when inputID is null', () => { - const errors = {username: 'Username cannot be empty'}; + const errors: Errors = {username: 'Username cannot be empty'}; ErrorUtils.addErrorMessage(errors, null, 'InputID cannot be null'); expect(errors).toEqual({username: 'Username cannot be empty'}); }); test('should not mutate the errors object when message is null', () => { - const errors = {username: 'Username cannot be empty'}; + const errors: Errors = {username: 'Username cannot be empty'}; ErrorUtils.addErrorMessage(errors, 'username', null); expect(errors).toEqual({username: 'Username cannot be empty'}); }); test('should add multiple error messages for the same inputID', () => { - const errors = {}; + const errors: Errors = {}; ErrorUtils.addErrorMessage(errors, 'username', 'Username cannot be empty'); ErrorUtils.addErrorMessage(errors, 'username', 'Username must be at least 6 characters long'); ErrorUtils.addErrorMessage(errors, 'username', 'Username must contain at least one letter'); @@ -53,7 +54,7 @@ describe('ErrorUtils', () => { }); test('should append multiple error messages to an existing error message for the same inputID', () => { - const errors = {username: 'Username cannot be empty\nUsername must be at least 6 characters long'}; + const errors: Errors = {username: 'Username cannot be empty\nUsername must be at least 6 characters long'}; ErrorUtils.addErrorMessage(errors, 'username', 'Username must contain at least one letter'); ErrorUtils.addErrorMessage(errors, 'username', 'Username must not contain special characters'); diff --git a/tests/unit/generateMonthMatrixTest.js b/tests/unit/generateMonthMatrixTest.ts similarity index 85% rename from tests/unit/generateMonthMatrixTest.js rename to tests/unit/generateMonthMatrixTest.ts index 67dd65e6b1fd..6f81ebc5c585 100644 --- a/tests/unit/generateMonthMatrixTest.js +++ b/tests/unit/generateMonthMatrixTest.ts @@ -1,8 +1,10 @@ -import generateMonthMatrix from '../../src/components/DatePicker/CalendarPicker/generateMonthMatrix'; +import generateMonthMatrix from '@src/components/DatePicker/CalendarPicker/generateMonthMatrix'; + +type MonthMatrix = Array>; describe('generateMonthMatrix', () => { it('returns the correct matrix for January 2022', () => { - const expected = [ + const expected: MonthMatrix = [ [null, null, null, null, null, 1, 2], [3, 4, 5, 6, 7, 8, 9], [10, 11, 12, 13, 14, 15, 16], @@ -14,7 +16,7 @@ describe('generateMonthMatrix', () => { }); it('returns the correct matrix for February 2022', () => { - const expected = [ + const expected: MonthMatrix = [ [null, 1, 2, 3, 4, 5, 6], [7, 8, 9, 10, 11, 12, 13], [14, 15, 16, 17, 18, 19, 20], @@ -25,7 +27,7 @@ describe('generateMonthMatrix', () => { }); it('returns the correct matrix for leap year February 2020', () => { - const expected = [ + const expected: MonthMatrix = [ [null, null, null, null, null, 1, 2], [3, 4, 5, 6, 7, 8, 9], [10, 11, 12, 13, 14, 15, 16], @@ -36,7 +38,7 @@ describe('generateMonthMatrix', () => { }); it('returns the correct matrix for March 2022', () => { - const expected = [ + const expected: MonthMatrix = [ [null, 1, 2, 3, 4, 5, 6], [7, 8, 9, 10, 11, 12, 13], [14, 15, 16, 17, 18, 19, 20], @@ -47,7 +49,7 @@ describe('generateMonthMatrix', () => { }); it('returns the correct matrix for April 2022', () => { - const expected = [ + const expected: MonthMatrix = [ [null, null, null, null, 1, 2, 3], [4, 5, 6, 7, 8, 9, 10], [11, 12, 13, 14, 15, 16, 17], @@ -58,7 +60,7 @@ describe('generateMonthMatrix', () => { }); it('returns the correct matrix for December 2022', () => { - const expected = [ + const expected: MonthMatrix = [ [null, null, null, 1, 2, 3, 4], [5, 6, 7, 8, 9, 10, 11], [12, 13, 14, 15, 16, 17, 18], @@ -69,7 +71,7 @@ describe('generateMonthMatrix', () => { }); it('returns the correct matrix for January 2025', () => { - const expected = [ + const expected: MonthMatrix = [ [null, null, 1, 2, 3, 4, 5], [6, 7, 8, 9, 10, 11, 12], [13, 14, 15, 16, 17, 18, 19], @@ -80,7 +82,7 @@ describe('generateMonthMatrix', () => { }); it('returns the correct matrix for February 2025', () => { - const expected = [ + const expected: MonthMatrix = [ [null, null, null, null, null, 1, 2], [3, 4, 5, 6, 7, 8, 9], [10, 11, 12, 13, 14, 15, 16], @@ -91,7 +93,7 @@ describe('generateMonthMatrix', () => { }); it('returns the correct matrix for June 2025', () => { - const expected = [ + const expected: MonthMatrix = [ [null, null, null, null, null, null, 1], [2, 3, 4, 5, 6, 7, 8], [9, 10, 11, 12, 13, 14, 15], @@ -103,7 +105,7 @@ describe('generateMonthMatrix', () => { }); it('returns the correct matrix for December 2025', () => { - const expected = [ + const expected: MonthMatrix = [ [1, 2, 3, 4, 5, 6, 7], [8, 9, 10, 11, 12, 13, 14], [15, 16, 17, 18, 19, 20, 21], @@ -125,13 +127,6 @@ describe('generateMonthMatrix', () => { expect(() => generateMonthMatrix(-1, 0)).toThrow(); }); - it('throws an error if year or month is not a number', () => { - expect(() => generateMonthMatrix()).toThrow(); - expect(() => generateMonthMatrix(2022, 'invalid')).toThrow(); - expect(() => generateMonthMatrix('2022', '0')).toThrow(); - expect(() => generateMonthMatrix(null, undefined)).toThrow(); - }); - it('returns a matrix with 6 rows and 7 columns for January 2022', () => { const matrix = generateMonthMatrix(2022, 0); expect(matrix.length).toBe(6);