diff --git a/.eslintrc.js b/.eslintrc.js
index 0661183101ab..33be8cb62fcd 100644
--- a/.eslintrc.js
+++ b/.eslintrc.js
@@ -51,6 +51,10 @@ const restrictedImportPaths = [
name: '@styles/theme/illustrations',
message: 'Do not import theme illustrations directly. Please use the `useThemeIllustrations` hook instead.',
},
+ {
+ name: 'date-fns/locale',
+ message: "Do not import 'date-fns/locale' directly. Please use the submodule import instead, like 'date-fns/locale/en-GB'.",
+ },
];
const restrictedImportPatterns = [
diff --git a/android/app/build.gradle b/android/app/build.gradle
index c919f731795e..b6d3f43cff98 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 1001045302
- versionName "1.4.53-2"
+ versionCode 1001045400
+ versionName "1.4.54-0"
}
flavorDimensions "default"
diff --git a/ios/NewExpensify/Info.plist b/ios/NewExpensify/Info.plist
index 2e4c69affd9b..14047e7a7f40 100644
--- a/ios/NewExpensify/Info.plist
+++ b/ios/NewExpensify/Info.plist
@@ -19,7 +19,7 @@
CFBundlePackageType
APPL
CFBundleShortVersionString
- 1.4.53
+ 1.4.54
CFBundleSignature
????
CFBundleURLTypes
@@ -40,7 +40,7 @@
CFBundleVersion
- 1.4.53.2
+ 1.4.54.0
ITSAppUsesNonExemptEncryption
LSApplicationQueriesSchemes
diff --git a/ios/NewExpensifyTests/Info.plist b/ios/NewExpensifyTests/Info.plist
index b6aab5371ba4..a1e74152930b 100644
--- a/ios/NewExpensifyTests/Info.plist
+++ b/ios/NewExpensifyTests/Info.plist
@@ -15,10 +15,10 @@
CFBundlePackageType
BNDL
CFBundleShortVersionString
- 1.4.53
+ 1.4.54
CFBundleSignature
????
CFBundleVersion
- 1.4.53.2
+ 1.4.54.0
diff --git a/ios/NotificationServiceExtension/Info.plist b/ios/NotificationServiceExtension/Info.plist
index 6b0ad08aad65..33651bb71380 100644
--- a/ios/NotificationServiceExtension/Info.plist
+++ b/ios/NotificationServiceExtension/Info.plist
@@ -11,9 +11,9 @@
CFBundleName
$(PRODUCT_NAME)
CFBundleShortVersionString
- 1.4.53
+ 1.4.54
CFBundleVersion
- 1.4.53.2
+ 1.4.54.0
NSExtension
NSExtensionPointIdentifier
diff --git a/package-lock.json b/package-lock.json
index a84a72dd5167..15297a6df18c 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "new.expensify",
- "version": "1.4.53-2",
+ "version": "1.4.54-0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "new.expensify",
- "version": "1.4.53-2",
+ "version": "1.4.54-0",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
@@ -56,7 +56,6 @@
"expo-av": "~13.10.4",
"expo-image": "1.10.1",
"expo-image-manipulator": "11.8.0",
- "fbjs": "^3.0.2",
"htmlparser2": "^7.2.0",
"idb-keyval": "^6.2.1",
"jest-expo": "50.0.1",
@@ -65,7 +64,6 @@
"lottie-react-native": "6.4.1",
"mapbox-gl": "^2.15.0",
"onfido-sdk-ui": "14.15.0",
- "patch-package": "^8.0.0",
"process": "^0.11.10",
"prop-types": "^15.7.2",
"pusher-js": "8.3.0",
@@ -89,7 +87,6 @@
"react-native-gesture-handler": "2.14.1",
"react-native-google-places-autocomplete": "2.5.6",
"react-native-haptic-feedback": "^2.2.0",
- "react-native-image-pan-zoom": "^2.1.12",
"react-native-image-picker": "^7.0.3",
"react-native-image-size": "git+https://github.com/Expensify/react-native-image-size#8393b7e58df6ff65fd41f60aee8ece8822c91e2b",
"react-native-key-command": "^1.0.6",
@@ -127,7 +124,6 @@
"react-web-config": "^1.0.0",
"react-webcam": "^7.1.1",
"react-window": "^1.8.9",
- "save": "^2.4.0",
"semver": "^7.5.2",
"shim-keyboard-event-key": "^1.0.3",
"underscore": "^1.13.1"
@@ -225,6 +221,7 @@
"jest-transformer-svg": "^2.0.1",
"memfs": "^4.6.0",
"onchange": "^7.1.0",
+ "patch-package": "^8.0.0",
"portfinder": "^1.0.28",
"prettier": "^2.8.8",
"pusher-js-mock": "^0.3.3",
@@ -19786,6 +19783,9 @@
},
"node_modules/@yarnpkg/lockfile": {
"version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/@yarnpkg/lockfile/-/lockfile-1.1.0.tgz",
+ "integrity": "sha512-GpSwvyXOcOOlV70vbnzjj4fW5xW/FdUF6nQEt1ENy7m4ZCczi1+/buVUPAqmGfqznsORNFzUMjctTIp8a9tuCQ==",
+ "dev": true,
"license": "BSD-2-Clause"
},
"node_modules/7zip-bin": {
@@ -20721,6 +20721,9 @@
},
"node_modules/async": {
"version": "3.2.4",
+ "resolved": "https://registry.npmjs.org/async/-/async-3.2.4.tgz",
+ "integrity": "sha512-iAB+JbDEGXhyIUavoDl9WP/Jj106Kz9DEn1DPgYw5ruDn0e3Wgi3sKFm55sASdGBNOQB8F59d9qQ7deqrHA8wQ==",
+ "dev": true,
"license": "MIT"
},
"node_modules/async-each": {
@@ -25442,6 +25445,9 @@
},
"node_modules/duplexer": {
"version": "0.1.2",
+ "resolved": "https://registry.npmjs.org/duplexer/-/duplexer-0.1.2.tgz",
+ "integrity": "sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg==",
+ "dev": true,
"license": "MIT"
},
"node_modules/duplexify": {
@@ -27058,19 +27064,6 @@
"node": ">= 0.6"
}
},
- "node_modules/event-stream": {
- "version": "4.0.1",
- "license": "MIT",
- "dependencies": {
- "duplexer": "^0.1.1",
- "from": "^0.1.7",
- "map-stream": "0.0.7",
- "pause-stream": "^0.0.11",
- "split": "^1.0.1",
- "stream-combiner": "^0.2.2",
- "through": "^2.3.8"
- }
- },
"node_modules/event-target-shim": {
"version": "5.0.1",
"license": "MIT",
@@ -28551,10 +28544,6 @@
"node": ">= 0.6"
}
},
- "node_modules/from": {
- "version": "0.1.7",
- "license": "MIT"
- },
"node_modules/from2": {
"version": "2.3.0",
"dev": true,
@@ -34135,6 +34124,9 @@
},
"node_modules/klaw-sync": {
"version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/klaw-sync/-/klaw-sync-6.0.0.tgz",
+ "integrity": "sha512-nIeuVSzdCCs6TDPTqI8w1Yre34sSq7AkZ4B3sfOBbI2CgVSB4Du4aLQijFU2+lhAFCwt9+42Hel6lQNIv6AntQ==",
+ "dev": true,
"license": "MIT",
"dependencies": {
"graceful-fs": "^4.1.11"
@@ -34413,10 +34405,6 @@
"version": "4.17.21",
"license": "MIT"
},
- "node_modules/lodash.assign": {
- "version": "4.2.0",
- "license": "MIT"
- },
"node_modules/lodash.clonedeep": {
"version": "4.5.0",
"dev": true,
@@ -34855,10 +34843,6 @@
"dev": true,
"license": "MIT"
},
- "node_modules/map-stream": {
- "version": "0.0.7",
- "license": "MIT"
- },
"node_modules/map-visit": {
"version": "1.0.0",
"devOptional": true,
@@ -35996,10 +35980,6 @@
"node": ">=4"
}
},
- "node_modules/mingo": {
- "version": "1.3.3",
- "license": "MIT"
- },
"node_modules/minimalistic-assert": {
"version": "1.0.1",
"license": "ISC"
@@ -37409,6 +37389,9 @@
},
"node_modules/patch-package": {
"version": "8.0.0",
+ "resolved": "https://registry.npmjs.org/patch-package/-/patch-package-8.0.0.tgz",
+ "integrity": "sha512-da8BVIhzjtgScwDJ2TtKsfT5JFWz1hYoBl9rUQ1f38MC2HwnEIkK8VN3dKMKcP7P7bvvgzNDbfNHtx3MsQb5vA==",
+ "dev": true,
"license": "MIT",
"dependencies": {
"@yarnpkg/lockfile": "^1.1.0",
@@ -37437,6 +37420,9 @@
},
"node_modules/patch-package/node_modules/ansi-styles": {
"version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
+ "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
+ "dev": true,
"license": "MIT",
"dependencies": {
"color-convert": "^2.0.1"
@@ -37450,6 +37436,9 @@
},
"node_modules/patch-package/node_modules/chalk": {
"version": "4.1.2",
+ "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
+ "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
+ "dev": true,
"license": "MIT",
"dependencies": {
"ansi-styles": "^4.1.0",
@@ -37464,6 +37453,9 @@
},
"node_modules/patch-package/node_modules/color-convert": {
"version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
+ "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
+ "dev": true,
"license": "MIT",
"dependencies": {
"color-name": "~1.1.4"
@@ -37474,10 +37466,16 @@
},
"node_modules/patch-package/node_modules/color-name": {
"version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
+ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
+ "dev": true,
"license": "MIT"
},
"node_modules/patch-package/node_modules/has-flag": {
"version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
+ "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
+ "dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
@@ -37485,6 +37483,9 @@
},
"node_modules/patch-package/node_modules/open": {
"version": "7.4.2",
+ "resolved": "https://registry.npmjs.org/open/-/open-7.4.2.tgz",
+ "integrity": "sha512-MVHddDVweXZF3awtlAS+6pgKLlm/JgxZ90+/NBurBoQctVOOB/zDdVjcyPzQ+0laDGbsWgrRkflI65sQeOgT9Q==",
+ "dev": true,
"license": "MIT",
"dependencies": {
"is-docker": "^2.0.0",
@@ -37499,6 +37500,9 @@
},
"node_modules/patch-package/node_modules/rimraf": {
"version": "2.7.1",
+ "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.7.1.tgz",
+ "integrity": "sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==",
+ "dev": true,
"license": "ISC",
"dependencies": {
"glob": "^7.1.3"
@@ -37509,6 +37513,9 @@
},
"node_modules/patch-package/node_modules/slash": {
"version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/slash/-/slash-2.0.0.tgz",
+ "integrity": "sha512-ZYKh3Wh2z1PpEXWr0MpSBZ0V6mZHAQfYevttO11c51CaWjGTaadiKZ+wVt1PbMlDV5qhMFslpZCemhwOK7C89A==",
+ "dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
@@ -37516,6 +37523,9 @@
},
"node_modules/patch-package/node_modules/supports-color": {
"version": "7.2.0",
+ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
+ "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
+ "dev": true,
"license": "MIT",
"dependencies": {
"has-flag": "^4.0.0"
@@ -37526,6 +37536,9 @@
},
"node_modules/patch-package/node_modules/tmp": {
"version": "0.0.33",
+ "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.0.33.tgz",
+ "integrity": "sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw==",
+ "dev": true,
"license": "MIT",
"dependencies": {
"os-tmpdir": "~1.0.2"
@@ -37622,16 +37635,6 @@
"node": ">=8"
}
},
- "node_modules/pause-stream": {
- "version": "0.0.11",
- "license": [
- "MIT",
- "Apache2"
- ],
- "dependencies": {
- "through": "~2.3"
- }
- },
"node_modules/pbf": {
"version": "3.2.1",
"license": "BSD-3-Clause",
@@ -39089,14 +39092,6 @@
"react-native": ">=0.60.0"
}
},
- "node_modules/react-native-image-pan-zoom": {
- "version": "2.1.12",
- "license": "ISC",
- "peerDependencies": {
- "react": "*",
- "react-native": "*"
- }
- },
"node_modules/react-native-image-picker": {
"version": "7.0.3",
"license": "MIT",
@@ -41261,16 +41256,6 @@
"truncate-utf8-bytes": "^1.0.0"
}
},
- "node_modules/save": {
- "version": "2.5.0",
- "license": "ISC",
- "dependencies": {
- "async": "^3.2.2",
- "event-stream": "^4.0.1",
- "lodash.assign": "^4.2.0",
- "mingo": "1"
- }
- },
"node_modules/sax": {
"version": "1.2.4",
"license": "ISC"
@@ -42461,14 +42446,6 @@
"node": ">= 0.10.0"
}
},
- "node_modules/stream-combiner": {
- "version": "0.2.2",
- "license": "MIT",
- "dependencies": {
- "duplexer": "~0.1.1",
- "through": "~2.3.4"
- }
- },
"node_modules/stream-each": {
"version": "1.2.3",
"dev": true,
diff --git a/package.json b/package.json
index e0f357fd0f8a..096c3584913a 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "new.expensify",
- "version": "1.4.53-2",
+ "version": "1.4.54-0",
"author": "Expensify, Inc.",
"homepage": "https://new.expensify.com",
"description": "New Expensify is the next generation of Expensify: a reimagination of payments based atop a foundation of chat.",
@@ -107,7 +107,6 @@
"expo-av": "~13.10.4",
"expo-image": "1.10.1",
"expo-image-manipulator": "11.8.0",
- "fbjs": "^3.0.2",
"htmlparser2": "^7.2.0",
"idb-keyval": "^6.2.1",
"jest-expo": "50.0.1",
@@ -116,7 +115,6 @@
"lottie-react-native": "6.4.1",
"mapbox-gl": "^2.15.0",
"onfido-sdk-ui": "14.15.0",
- "patch-package": "^8.0.0",
"process": "^0.11.10",
"prop-types": "^15.7.2",
"pusher-js": "8.3.0",
@@ -140,7 +138,6 @@
"react-native-gesture-handler": "2.14.1",
"react-native-google-places-autocomplete": "2.5.6",
"react-native-haptic-feedback": "^2.2.0",
- "react-native-image-pan-zoom": "^2.1.12",
"react-native-image-picker": "^7.0.3",
"react-native-image-size": "git+https://github.com/Expensify/react-native-image-size#8393b7e58df6ff65fd41f60aee8ece8822c91e2b",
"react-native-key-command": "^1.0.6",
@@ -178,7 +175,6 @@
"react-web-config": "^1.0.0",
"react-webcam": "^7.1.1",
"react-window": "^1.8.9",
- "save": "^2.4.0",
"semver": "^7.5.2",
"shim-keyboard-event-key": "^1.0.3",
"underscore": "^1.13.1"
@@ -276,6 +272,7 @@
"jest-transformer-svg": "^2.0.1",
"memfs": "^4.6.0",
"onchange": "^7.1.0",
+ "patch-package": "^8.0.0",
"portfinder": "^1.0.28",
"prettier": "^2.8.8",
"pusher-js-mock": "^0.3.3",
diff --git a/patches/react-native-web+0.19.9+005+image-header-support.patch b/patches/react-native-web+0.19.9+005+image-header-support.patch
new file mode 100644
index 000000000000..4652e22662f0
--- /dev/null
+++ b/patches/react-native-web+0.19.9+005+image-header-support.patch
@@ -0,0 +1,200 @@
+diff --git a/node_modules/react-native-web/dist/exports/Image/index.js b/node_modules/react-native-web/dist/exports/Image/index.js
+index 95355d5..19109fc 100644
+--- a/node_modules/react-native-web/dist/exports/Image/index.js
++++ b/node_modules/react-native-web/dist/exports/Image/index.js
+@@ -135,7 +135,22 @@ function resolveAssetUri(source) {
+ }
+ return uri;
+ }
+-var Image = /*#__PURE__*/React.forwardRef((props, ref) => {
++function raiseOnErrorEvent(uri, _ref) {
++ var onError = _ref.onError,
++ onLoadEnd = _ref.onLoadEnd;
++ if (onError) {
++ onError({
++ nativeEvent: {
++ error: "Failed to load resource " + uri + " (404)"
++ }
++ });
++ }
++ if (onLoadEnd) onLoadEnd();
++}
++function hasSourceDiff(a, b) {
++ return a.uri !== b.uri || JSON.stringify(a.headers) !== JSON.stringify(b.headers);
++}
++var BaseImage = /*#__PURE__*/React.forwardRef((props, ref) => {
+ var ariaLabel = props['aria-label'],
+ blurRadius = props.blurRadius,
+ defaultSource = props.defaultSource,
+@@ -236,16 +251,10 @@ var Image = /*#__PURE__*/React.forwardRef((props, ref) => {
+ }
+ }, function error() {
+ updateState(ERRORED);
+- if (onError) {
+- onError({
+- nativeEvent: {
+- error: "Failed to load resource " + uri + " (404)"
+- }
+- });
+- }
+- if (onLoadEnd) {
+- onLoadEnd();
+- }
++ raiseOnErrorEvent(uri, {
++ onError,
++ onLoadEnd
++ });
+ });
+ }
+ function abortPendingRequest() {
+@@ -277,10 +286,78 @@ var Image = /*#__PURE__*/React.forwardRef((props, ref) => {
+ suppressHydrationWarning: true
+ }), hiddenImage, createTintColorSVG(tintColor, filterRef.current));
+ });
+-Image.displayName = 'Image';
++BaseImage.displayName = 'Image';
++
++/**
++ * This component handles specifically loading an image source with headers
++ * default source is never loaded using headers
++ */
++var ImageWithHeaders = /*#__PURE__*/React.forwardRef((props, ref) => {
++ // $FlowIgnore: This component would only be rendered when `source` matches `ImageSource`
++ var nextSource = props.source;
++ var _React$useState3 = React.useState(''),
++ blobUri = _React$useState3[0],
++ setBlobUri = _React$useState3[1];
++ var request = React.useRef({
++ cancel: () => {},
++ source: {
++ uri: '',
++ headers: {}
++ },
++ promise: Promise.resolve('')
++ });
++ var onError = props.onError,
++ onLoadStart = props.onLoadStart,
++ onLoadEnd = props.onLoadEnd;
++ React.useEffect(() => {
++ if (!hasSourceDiff(nextSource, request.current.source)) {
++ return;
++ }
++
++ // When source changes we want to clean up any old/running requests
++ request.current.cancel();
++ if (onLoadStart) {
++ onLoadStart();
++ }
++
++ // Store a ref for the current load request so we know what's the last loaded source,
++ // and so we can cancel it if a different source is passed through props
++ request.current = ImageLoader.loadWithHeaders(nextSource);
++ request.current.promise.then(uri => setBlobUri(uri)).catch(() => raiseOnErrorEvent(request.current.source.uri, {
++ onError,
++ onLoadEnd
++ }));
++ }, [nextSource, onLoadStart, onError, onLoadEnd]);
++
++ // Cancel any request on unmount
++ React.useEffect(() => request.current.cancel, []);
++ var propsToPass = _objectSpread(_objectSpread({}, props), {}, {
++ // `onLoadStart` is called from the current component
++ // We skip passing it down to prevent BaseImage raising it a 2nd time
++ onLoadStart: undefined,
++ // Until the current component resolves the request (using headers)
++ // we skip forwarding the source so the base component doesn't attempt
++ // to load the original source
++ source: blobUri ? _objectSpread(_objectSpread({}, nextSource), {}, {
++ uri: blobUri
++ }) : undefined
++ });
++ return /*#__PURE__*/React.createElement(BaseImage, _extends({
++ ref: ref
++ }, propsToPass));
++});
+
+ // $FlowIgnore: This is the correct type, but casting makes it unhappy since the variables aren't defined yet
+-var ImageWithStatics = Image;
++var ImageWithStatics = /*#__PURE__*/React.forwardRef((props, ref) => {
++ if (props.source && props.source.headers) {
++ return /*#__PURE__*/React.createElement(ImageWithHeaders, _extends({
++ ref: ref
++ }, props));
++ }
++ return /*#__PURE__*/React.createElement(BaseImage, _extends({
++ ref: ref
++ }, props));
++});
+ ImageWithStatics.getSize = function (uri, success, failure) {
+ ImageLoader.getSize(uri, success, failure);
+ };
+diff --git a/node_modules/react-native-web/dist/modules/ImageLoader/index.js b/node_modules/react-native-web/dist/modules/ImageLoader/index.js
+index bc06a87..e309394 100644
+--- a/node_modules/react-native-web/dist/modules/ImageLoader/index.js
++++ b/node_modules/react-native-web/dist/modules/ImageLoader/index.js
+@@ -76,7 +76,7 @@ var ImageLoader = {
+ var image = requests["" + requestId];
+ if (image) {
+ var naturalHeight = image.naturalHeight,
+- naturalWidth = image.naturalWidth;
++ naturalWidth = image.naturalWidth;
+ if (naturalHeight && naturalWidth) {
+ success(naturalWidth, naturalHeight);
+ complete = true;
+@@ -102,11 +102,19 @@ var ImageLoader = {
+ id += 1;
+ var image = new window.Image();
+ image.onerror = onError;
+- image.onload = e => {
++ image.onload = nativeEvent => {
+ // avoid blocking the main thread
+- var onDecode = () => onLoad({
+- nativeEvent: e
+- });
++ var onDecode = () => {
++ // Append `source` to match RN's ImageLoadEvent interface
++ nativeEvent.source = {
++ uri: image.src,
++ width: image.naturalWidth,
++ height: image.naturalHeight
++ };
++ onLoad({
++ nativeEvent
++ });
++ };
+ if (typeof image.decode === 'function') {
+ // Safari currently throws exceptions when decoding svgs.
+ // We want to catch that error and allow the load handler
+@@ -120,6 +128,32 @@ var ImageLoader = {
+ requests["" + id] = image;
+ return id;
+ },
++ loadWithHeaders(source) {
++ var uri;
++ var abortController = new AbortController();
++ var request = new Request(source.uri, {
++ headers: source.headers,
++ signal: abortController.signal
++ });
++ request.headers.append('accept', 'image/*');
++ var promise = fetch(request).then(response => response.blob()).then(blob => {
++ uri = URL.createObjectURL(blob);
++ return uri;
++ }).catch(error => {
++ if (error.name === 'AbortError') {
++ return '';
++ }
++ throw error;
++ });
++ return {
++ promise,
++ source,
++ cancel: () => {
++ abortController.abort();
++ URL.revokeObjectURL(uri);
++ }
++ };
++ },
+ prefetch(uri) {
+ return new Promise((resolve, reject) => {
+ ImageLoader.load(uri, () => {
diff --git a/src/CONST.ts b/src/CONST.ts
index c3e61c0c75d8..132b49c16ef7 100755
--- a/src/CONST.ts
+++ b/src/CONST.ts
@@ -43,10 +43,21 @@ const keyInputRightArrow = KeyCommand?.constants?.keyInputRightArrow ?? 'keyInpu
// describes if a shortcut key can cause navigation
const KEYBOARD_SHORTCUT_NAVIGATION_TYPE = 'NAVIGATION_SHORTCUT';
+const chatTypes = {
+ POLICY_ANNOUNCE: 'policyAnnounce',
+ POLICY_ADMINS: 'policyAdmins',
+ DOMAIN_ALL: 'domainAll',
+ POLICY_ROOM: 'policyRoom',
+ POLICY_EXPENSE_CHAT: 'policyExpenseChat',
+ SELF_DM: 'selfDM',
+} as const;
+
// Explicit type annotation is required
const cardActiveStates: number[] = [2, 3, 4, 7];
const CONST = {
+ MERGED_ACCOUNT_PREFIX: 'MERGED_',
+ DEFAULT_POLICY_ROOM_CHAT_TYPES: [chatTypes.POLICY_ADMINS, chatTypes.POLICY_ANNOUNCE, chatTypes.DOMAIN_ALL],
ANDROID_PACKAGE_NAME,
ANIMATED_TRANSITION: 300,
ANIMATED_TRANSITION_FROM_VALUE: 100,
@@ -346,6 +357,9 @@ const CONST = {
INSTALLED: 'installed',
NOT_INSTALLED: 'not-installed',
},
+ TAX_RATES: {
+ NAME_MAX_LENGTH: 50,
+ },
PLATFORM: {
IOS: 'ios',
ANDROID: 'android',
@@ -732,14 +746,7 @@ const CONST = {
IOU: 'iou',
TASK: 'task',
},
- CHAT_TYPE: {
- POLICY_ANNOUNCE: 'policyAnnounce',
- POLICY_ADMINS: 'policyAdmins',
- DOMAIN_ALL: 'domainAll',
- POLICY_ROOM: 'policyRoom',
- POLICY_EXPENSE_CHAT: 'policyExpenseChat',
- SELF_DM: 'selfDM',
- },
+ CHAT_TYPE: chatTypes,
WORKSPACE_CHAT_ROOMS: {
ANNOUNCE: '#announce',
ADMINS: '#admins',
diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts
index d7f3104cd8b4..e91b4d491423 100755
--- a/src/ONYXKEYS.ts
+++ b/src/ONYXKEYS.ts
@@ -339,6 +339,8 @@ const ONYXKEYS = {
WORKSPACE_DESCRIPTION_FORM_DRAFT: 'workspaceDescriptionFormDraft',
WORKSPACE_RATE_AND_UNIT_FORM: 'workspaceRateAndUnitForm',
WORKSPACE_RATE_AND_UNIT_FORM_DRAFT: 'workspaceRateAndUnitFormDraft',
+ WORKSPACE_TAX_CUSTOM_NAME: 'workspaceTaxCustomName',
+ WORKSPACE_TAX_CUSTOM_NAME_DRAFT: 'workspaceTaxCustomNameDraft',
POLICY_CREATE_DISTANCE_RATE_FORM: 'policyCreateDistanceRateForm',
POLICY_CREATE_DISTANCE_RATE_FORM_DRAFT: 'policyCreateDistanceRateFormDraft',
CLOSE_ACCOUNT_FORM: 'closeAccount',
@@ -411,6 +413,8 @@ const ONYXKEYS = {
EXIT_SURVEY_RESPONSE_FORM_DRAFT: 'exitSurveyResponseFormDraft',
POLICY_TAG_NAME_FORM: 'policyTagNameForm',
POLICY_TAG_NAME_FORM_DRAFT: 'policyTagNameFormDraft',
+ WORKSPACE_NEW_TAX_FORM: 'workspaceNewTaxForm',
+ WORKSPACE_NEW_TAX_FORM_DRAFT: 'workspaceNewTaxFormDraft',
},
} as const;
@@ -422,6 +426,7 @@ type OnyxFormValuesMapping = {
[ONYXKEYS.FORMS.WORKSPACE_CATEGORY_FORM]: FormTypes.WorkspaceCategoryForm;
[ONYXKEYS.FORMS.WORKSPACE_TAG_CREATE_FORM]: FormTypes.WorkspaceTagCreateForm;
[ONYXKEYS.FORMS.WORKSPACE_RATE_AND_UNIT_FORM]: FormTypes.WorkspaceRateAndUnitForm;
+ [ONYXKEYS.FORMS.WORKSPACE_TAX_CUSTOM_NAME]: FormTypes.WorkspaceTaxCustomName;
[ONYXKEYS.FORMS.CLOSE_ACCOUNT_FORM]: FormTypes.CloseAccountForm;
[ONYXKEYS.FORMS.PROFILE_SETTINGS_FORM]: FormTypes.ProfileSettingsForm;
[ONYXKEYS.FORMS.DISPLAY_NAME_FORM]: FormTypes.DisplayNameForm;
@@ -458,6 +463,7 @@ type OnyxFormValuesMapping = {
[ONYXKEYS.FORMS.PERSONAL_BANK_ACCOUNT]: FormTypes.PersonalBankAccountForm;
[ONYXKEYS.FORMS.WORKSPACE_DESCRIPTION_FORM]: FormTypes.WorkspaceDescriptionForm;
[ONYXKEYS.FORMS.POLICY_TAG_NAME_FORM]: FormTypes.PolicyTagNameForm;
+ [ONYXKEYS.FORMS.WORKSPACE_NEW_TAX_FORM]: FormTypes.WorkspaceNewTaxForm;
[ONYXKEYS.FORMS.POLICY_CREATE_DISTANCE_RATE_FORM]: FormTypes.PolicyCreateDistanceRateForm;
};
diff --git a/src/ROUTES.ts b/src/ROUTES.ts
index 1bcf6fc02032..5769b60a8284 100644
--- a/src/ROUTES.ts
+++ b/src/ROUTES.ts
@@ -276,12 +276,6 @@ const ROUTES = {
route: 'r/:reportID/invite',
getRoute: (reportID: string) => `r/${reportID}/invite` as const,
},
-
- // To see the available iouType, please refer to CONST.IOU.TYPE
- MONEY_REQUEST: {
- route: ':iouType/new/:reportID?',
- getRoute: (iouType: string, reportID = '') => `${iouType}/new/${reportID}` as const,
- },
MONEY_REQUEST_AMOUNT: {
route: ':iouType/new/amount/:reportID?',
getRoute: (iouType: string, reportID = '') => `${iouType}/new/amount/${reportID}` as const,
@@ -314,13 +308,6 @@ const ROUTES = {
route: ':iouType/new/address/:reportID?',
getRoute: (iouType: string, reportID = '') => `${iouType}/new/address/${reportID}` as const,
},
- MONEY_REQUEST_DISTANCE_TAB: {
- route: ':iouType/new/:reportID?/distance',
- getRoute: (iouType: string, reportID = '') => `${iouType}/new/${reportID}/distance` as const,
- },
- MONEY_REQUEST_MANUAL_TAB: ':iouType/new/:reportID?/manual',
- MONEY_REQUEST_SCAN_TAB: ':iouType/new/:reportID?/scan',
-
MONEY_REQUEST_CREATE: {
route: 'create/:iouType/start/:transactionID/:reportID',
getRoute: (iouType: ValueOf, transactionID: string, reportID: string) => `create/${iouType}/start/${transactionID}/${reportID}` as const,
@@ -597,6 +584,22 @@ const ROUTES = {
route: 'settings/workspaces/:policyID/taxes',
getRoute: (policyID: string) => `settings/workspaces/${policyID}/taxes` as const,
},
+ WORKSPACE_TAXES_SETTINGS: {
+ route: 'settings/workspaces/:policyID/taxes/settings',
+ getRoute: (policyID: string) => `settings/workspaces/${policyID}/taxes/settings` as const,
+ },
+ WORKSPACE_TAXES_SETTINGS_WORKSPACE_CURRENCY_DEFAULT: {
+ route: 'settings/workspaces/:policyID/taxes/settings/workspace-currency',
+ getRoute: (policyID: string) => `settings/workspaces/${policyID}/taxes/settings/workspace-currency` as const,
+ },
+ WORKSPACE_TAXES_SETTINGS_FOREIGN_CURRENCY_DEFAULT: {
+ route: 'settings/workspaces/:policyID/taxes/settings/foreign-currency',
+ getRoute: (policyID: string) => `settings/workspaces/${policyID}/taxes/settings/foreign-currency` as const,
+ },
+ WORKSPACE_TAXES_SETTINGS_CUSTOM_TAX_NAME: {
+ route: 'settings/workspaces/:policyID/taxes/settings/tax-name',
+ getRoute: (policyID: string) => `settings/workspaces/${policyID}/taxes/settings/tax-name` as const,
+ },
WORKSPACE_MEMBER_DETAILS: {
route: 'settings/workspaces/:policyID/members/:accountID',
getRoute: (policyID: string, accountID: number, backTo?: string) => getUrlWithBackToParam(`settings/workspaces/${policyID}/members/${accountID}`, backTo),
@@ -605,6 +608,10 @@ const ROUTES = {
route: 'settings/workspaces/:policyID/members/:accountID/role-selection',
getRoute: (policyID: string, accountID: number, backTo?: string) => getUrlWithBackToParam(`settings/workspaces/${policyID}/members/${accountID}/role-selection`, backTo),
},
+ WORKSPACE_TAX_CREATE: {
+ route: 'settings/workspaces/:policyID/taxes/new',
+ getRoute: (policyID: string) => `settings/workspaces/${policyID}/taxes/new` as const,
+ },
WORKSPACE_DISTANCE_RATES: {
route: 'settings/workspaces/:policyID/distance-rates',
getRoute: (policyID: string) => `settings/workspaces/${policyID}/distance-rates` as const,
diff --git a/src/SCREENS.ts b/src/SCREENS.ts
index 5d872d194dc5..2fbd122f9972 100644
--- a/src/SCREENS.ts
+++ b/src/SCREENS.ts
@@ -132,9 +132,6 @@ const SCREENS = {
WORKSPACE_JOIN_USER: 'WorkspaceJoinUser',
MONEY_REQUEST: {
- MANUAL_TAB: 'manual',
- SCAN_TAB: 'scan',
- DISTANCE_TAB: 'distance',
CREATE: 'Money_Request_Create',
HOLD: 'Money_Request_Hold_Reason',
STEP_CONFIRMATION: 'Money_Request_Step_Confirmation',
@@ -152,7 +149,6 @@ const SCREENS = {
STEP_WAYPOINT: 'Money_Request_Step_Waypoint',
STEP_TAX_AMOUNT: 'Money_Request_Step_Tax_Amount',
STEP_TAX_RATE: 'Money_Request_Step_Tax_Rate',
- ROOT: 'Money_Request',
AMOUNT: 'Money_Request_Amount',
PARTICIPANTS: 'Money_Request_Participants',
CONFIRMATION: 'Money_Request_Confirmation',
@@ -220,6 +216,11 @@ const SCREENS = {
TAGS_SETTINGS: 'Tags_Settings',
TAGS_EDIT: 'Tags_Edit',
TAXES: 'Workspace_Taxes',
+ TAXES_SETTINGS: 'Workspace_Taxes_Settings',
+ TAXES_SETTINGS_CUSTOM_TAX_NAME: 'Workspace_Taxes_Settings_CustomTaxName',
+ TAXES_SETTINGS_WORKSPACE_CURRENCY_DEFAULT: 'Workspace_Taxes_Settings_WorkspaceCurrency',
+ TAXES_SETTINGS_FOREIGN_CURRENCY_DEFAULT: 'Workspace_Taxes_Settings_ForeignCurrency',
+ TAX_CREATE: 'Workspace_Tax_Create',
TAG_CREATE: 'Tag_Create',
TAG_SETTINGS: 'Tag_Settings',
CURRENCY: 'Workspace_Profile_Currency',
diff --git a/src/components/AmountForm.tsx b/src/components/AmountForm.tsx
index 8ae8f0674012..48035dd884bd 100644
--- a/src/components/AmountForm.tsx
+++ b/src/components/AmountForm.tsx
@@ -12,8 +12,9 @@ import * as MoneyRequestUtils from '@libs/MoneyRequestUtils';
import CONST from '@src/CONST';
import BigNumberPad from './BigNumberPad';
import FormHelpMessage from './FormHelpMessage';
-import type {BaseTextInputRef} from './TextInput/BaseTextInput/types';
+import type {BaseTextInputProps, BaseTextInputRef} from './TextInput/BaseTextInput/types';
import TextInputWithCurrencySymbol from './TextInputWithCurrencySymbol';
+import type TextInputWithCurrencySymbolProps from './TextInputWithCurrencySymbol/types';
type AmountFormProps = {
/** Amount supplied by the FormProvider */
@@ -36,7 +37,8 @@ type AmountFormProps = {
/** Whether the currency symbol is pressable */
isCurrencyPressable?: boolean;
-};
+} & Pick &
+ Pick;
/**
* Returns the new selection object based on the updated amount's length
@@ -51,7 +53,7 @@ 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, isCurrencyPressable = true}: AmountFormProps,
+ {value: amount, currency = CONST.CURRENCY.USD, extraDecimals = 0, errorText, onInputChange, onCurrencyButtonPress, isCurrencyPressable = true, ...rest}: AmountFormProps,
forwardedRef: ForwardedRef,
) {
const styles = useThemeStyles();
@@ -214,6 +216,8 @@ function AmountForm(
}}
onKeyPress={textInputKeyPress}
isCurrencyPressable={isCurrencyPressable}
+ // eslint-disable-next-line react/jsx-props-no-spreading
+ {...rest}
/>
{!!errorText && (
+
+
+
+
+
+
+
+
+
+ );
+}
+
+AmountSelectorModal.displayName = 'AmountSelectorModal';
+
+export default AmountSelectorModal;
diff --git a/src/components/AmountPicker/index.tsx b/src/components/AmountPicker/index.tsx
new file mode 100644
index 000000000000..701c75175c02
--- /dev/null
+++ b/src/components/AmountPicker/index.tsx
@@ -0,0 +1,65 @@
+import React, {forwardRef, useState} from 'react';
+import type {ForwardedRef} from 'react';
+import {View} from 'react-native';
+import FormHelpMessage from '@components/FormHelpMessage';
+import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription';
+import useStyleUtils from '@hooks/useStyleUtils';
+import useThemeStyles from '@hooks/useThemeStyles';
+import variables from '@styles/variables';
+import callOrReturn from '@src/types/utils/callOrReturn';
+import AmountSelectorModal from './AmountSelectorModal';
+import type {AmountPickerProps} from './types';
+
+function AmountPicker({value, description, title, errorText = '', onInputChange, furtherDetails, rightLabel, ...rest}: AmountPickerProps, forwardedRef: ForwardedRef) {
+ const styles = useThemeStyles();
+ const StyleUtils = useStyleUtils();
+ const [isPickerVisible, setIsPickerVisible] = useState(false);
+
+ const showPickerModal = () => {
+ setIsPickerVisible(true);
+ };
+
+ const hidePickerModal = () => {
+ setIsPickerVisible(false);
+ };
+
+ const updateInput = (updatedValue: string) => {
+ if (updatedValue !== value) {
+ onInputChange?.(updatedValue);
+ }
+ hidePickerModal();
+ };
+
+ const descStyle = !value || value.length === 0 ? StyleUtils.getFontSizeStyle(variables.fontSizeLabel) : null;
+
+ return (
+
+
+
+
+
+
+
+ );
+}
+
+AmountPicker.displayName = 'AmountPicker';
+
+export default forwardRef(AmountPicker);
diff --git a/src/components/AmountPicker/types.ts b/src/components/AmountPicker/types.ts
new file mode 100644
index 000000000000..f7025685d840
--- /dev/null
+++ b/src/components/AmountPicker/types.ts
@@ -0,0 +1,40 @@
+import type {AmountFormProps} from '@components/AmountForm';
+import type {MenuItemBaseProps} from '@components/MenuItem';
+import type {MaybePhraseKey} from '@libs/Localize';
+
+type AmountSelectorModalProps = {
+ /** Whether the modal is visible */
+ isVisible: boolean;
+
+ /** Current value */
+ value?: string;
+
+ /** Function to call when the user selects a item */
+ onValueSelected?: (value: string) => void;
+
+ /** Function to call when the user closes the modal */
+ onClose: () => void;
+} & Pick;
+
+type AmountPickerProps = {
+ /** Item to display */
+ value?: string;
+
+ /** A placeholder value to display */
+ title?: string | ((value?: string) => string);
+
+ /** Form Error description */
+ errorText?: MaybePhraseKey;
+
+ /** Callback to call when the input changes */
+ onInputChange?: (value: string | undefined) => void;
+
+ /** Text to display under the main menu item */
+ furtherDetails?: string;
+
+ /** Whether to show the tooltip text */
+ shouldShowTooltips?: boolean;
+} & Pick &
+ AmountFormProps;
+
+export type {AmountSelectorModalProps, AmountPickerProps};
diff --git a/src/components/AmountTextInput.tsx b/src/components/AmountTextInput.tsx
index aff3ff5b5e39..6842a3e1d335 100644
--- a/src/components/AmountTextInput.tsx
+++ b/src/components/AmountTextInput.tsx
@@ -5,7 +5,7 @@ import useThemeStyles from '@hooks/useThemeStyles';
import CONST from '@src/CONST';
import type {TextSelection} from './Composer/types';
import TextInput from './TextInput';
-import type {BaseTextInputRef} from './TextInput/BaseTextInput/types';
+import type {BaseTextInputProps, BaseTextInputRef} from './TextInput/BaseTextInput/types';
type AmountTextInputProps = {
/** Formatted amount in local currency */
@@ -31,10 +31,10 @@ type AmountTextInputProps = {
/** Function to call to handle key presses in the text input */
onKeyPress?: (event: NativeSyntheticEvent) => void;
-};
+} & Pick;
function AmountTextInput(
- {formattedAmount, onChangeAmount, placeholder, selection, onSelectionChange, style, touchableInputWrapperStyle, onKeyPress}: AmountTextInputProps,
+ {formattedAmount, onChangeAmount, placeholder, selection, onSelectionChange, style, touchableInputWrapperStyle, onKeyPress, ...rest}: AmountTextInputProps,
ref: ForwardedRef,
) {
const styles = useThemeStyles();
@@ -57,6 +57,8 @@ function AmountTextInput(
role={CONST.ROLE.PRESENTATION}
onKeyPress={onKeyPress as (event: NativeSyntheticEvent) => void}
touchableInputWrapperStyle={touchableInputWrapperStyle}
+ // eslint-disable-next-line react/jsx-props-no-spreading
+ {...rest}
/>
);
}
diff --git a/src/components/BigNumberPad.tsx b/src/components/BigNumberPad.tsx
index de43bf548a98..d01519a13513 100644
--- a/src/components/BigNumberPad.tsx
+++ b/src/components/BigNumberPad.tsx
@@ -71,6 +71,7 @@ function BigNumberPad({numberPressed, longPressHandlerStateChanged = () => {}, i
);
}
diff --git a/src/libs/Navigation/linkingConfig/FULL_SCREEN_TO_RHP_MAPPING.ts b/src/libs/Navigation/linkingConfig/FULL_SCREEN_TO_RHP_MAPPING.ts
index 8713cbbcf6ba..6f06df9bf2ff 100755
--- a/src/libs/Navigation/linkingConfig/FULL_SCREEN_TO_RHP_MAPPING.ts
+++ b/src/libs/Navigation/linkingConfig/FULL_SCREEN_TO_RHP_MAPPING.ts
@@ -11,7 +11,13 @@ const FULL_SCREEN_TO_RHP_MAPPING: Partial> = {
SCREENS.WORKSPACE.WORKFLOWS_AUTO_REPORTING_MONTHLY_OFFSET,
SCREENS.WORKSPACE.WORKFLOWS_PAYER,
],
- [SCREENS.WORKSPACE.TAGS]: [SCREENS.WORKSPACE.TAGS_SETTINGS, SCREENS.WORKSPACE.TAGS_EDIT, SCREENS.WORKSPACE.TAG_CREATE, SCREENS.WORKSPACE.TAG_SETTINGS],
+ [SCREENS.WORKSPACE.TAXES]: [
+ SCREENS.WORKSPACE.TAXES_SETTINGS,
+ SCREENS.WORKSPACE.TAXES_SETTINGS_CUSTOM_TAX_NAME,
+ SCREENS.WORKSPACE.TAXES_SETTINGS_FOREIGN_CURRENCY_DEFAULT,
+ SCREENS.WORKSPACE.TAXES_SETTINGS_WORKSPACE_CURRENCY_DEFAULT,
+ ],
+ [SCREENS.WORKSPACE.TAGS]: [SCREENS.WORKSPACE.TAGS_SETTINGS, SCREENS.WORKSPACE.TAGS_EDIT, SCREENS.WORKSPACE.TAG_CREATE, SCREENS.WORKSPACE.TAG_SETTINGS, SCREENS.WORKSPACE.TAX_CREATE],
[SCREENS.WORKSPACE.CATEGORIES]: [SCREENS.WORKSPACE.CATEGORY_CREATE, SCREENS.WORKSPACE.CATEGORY_SETTINGS, SCREENS.WORKSPACE.CATEGORIES_SETTINGS, SCREENS.WORKSPACE.CATEGORY_EDIT],
[SCREENS.WORKSPACE.DISTANCE_RATES]: [SCREENS.WORKSPACE.CREATE_DISTANCE_RATE],
};
diff --git a/src/libs/Navigation/linkingConfig/config.ts b/src/libs/Navigation/linkingConfig/config.ts
index 4f8ccb7ec2c9..ae8d47a69988 100644
--- a/src/libs/Navigation/linkingConfig/config.ts
+++ b/src/libs/Navigation/linkingConfig/config.ts
@@ -307,6 +307,18 @@ const config: LinkingOptions['config'] = {
tagName: (tagName: string) => decodeURIComponent(tagName),
},
},
+ [SCREENS.WORKSPACE.TAXES_SETTINGS]: {
+ path: ROUTES.WORKSPACE_TAXES_SETTINGS.route,
+ },
+ [SCREENS.WORKSPACE.TAXES_SETTINGS_CUSTOM_TAX_NAME]: {
+ path: ROUTES.WORKSPACE_TAXES_SETTINGS_CUSTOM_TAX_NAME.route,
+ },
+ [SCREENS.WORKSPACE.TAXES_SETTINGS_FOREIGN_CURRENCY_DEFAULT]: {
+ path: ROUTES.WORKSPACE_TAXES_SETTINGS_FOREIGN_CURRENCY_DEFAULT.route,
+ },
+ [SCREENS.WORKSPACE.TAXES_SETTINGS_WORKSPACE_CURRENCY_DEFAULT]: {
+ path: ROUTES.WORKSPACE_TAXES_SETTINGS_WORKSPACE_CURRENCY_DEFAULT.route,
+ },
[SCREENS.REIMBURSEMENT_ACCOUNT]: {
path: ROUTES.BANK_ACCOUNT_WITH_STEP_TO_OPEN.route,
exact: true,
@@ -330,6 +342,9 @@ const config: LinkingOptions['config'] = {
[SCREENS.SETTINGS.EXIT_SURVEY.CONFIRM]: {
path: ROUTES.SETTINGS_EXIT_SURVEY_CONFIRM.route,
},
+ [SCREENS.WORKSPACE.TAX_CREATE]: {
+ path: ROUTES.WORKSPACE_TAX_CREATE.route,
+ },
},
},
[SCREENS.RIGHT_MODAL.PRIVATE_NOTES]: {
@@ -470,24 +485,6 @@ const config: LinkingOptions['config'] = {
[SCREENS.MONEY_REQUEST.STEP_SCAN]: ROUTES.MONEY_REQUEST_STEP_SCAN.route,
[SCREENS.MONEY_REQUEST.STEP_TAG]: ROUTES.MONEY_REQUEST_STEP_TAG.route,
[SCREENS.MONEY_REQUEST.STEP_WAYPOINT]: ROUTES.MONEY_REQUEST_STEP_WAYPOINT.route,
- [SCREENS.MONEY_REQUEST.ROOT]: {
- path: ROUTES.MONEY_REQUEST.route,
- exact: true,
- screens: {
- [SCREENS.MONEY_REQUEST.MANUAL_TAB]: {
- path: ROUTES.MONEY_REQUEST_MANUAL_TAB,
- exact: true,
- },
- [SCREENS.MONEY_REQUEST.SCAN_TAB]: {
- path: ROUTES.MONEY_REQUEST_SCAN_TAB,
- exact: true,
- },
- [SCREENS.MONEY_REQUEST.DISTANCE_TAB]: {
- path: ROUTES.MONEY_REQUEST_DISTANCE_TAB.route,
- exact: true,
- },
- },
- },
[SCREENS.MONEY_REQUEST.AMOUNT]: ROUTES.MONEY_REQUEST_AMOUNT.route,
[SCREENS.MONEY_REQUEST.STEP_TAX_AMOUNT]: ROUTES.MONEY_REQUEST_STEP_TAX_AMOUNT.route,
[SCREENS.MONEY_REQUEST.STEP_TAX_RATE]: ROUTES.MONEY_REQUEST_STEP_TAX_RATE.route,
diff --git a/src/libs/Navigation/types.ts b/src/libs/Navigation/types.ts
index 5e6ecf7a47c7..4c9bdb579605 100644
--- a/src/libs/Navigation/types.ts
+++ b/src/libs/Navigation/types.ts
@@ -193,6 +193,18 @@ type SettingsNavigatorParamList = {
policyID: string;
tagName: string;
};
+ [SCREENS.WORKSPACE.TAXES_SETTINGS]: {
+ policyID: string;
+ };
+ [SCREENS.WORKSPACE.TAXES_SETTINGS_CUSTOM_TAX_NAME]: {
+ policyID: string;
+ };
+ [SCREENS.WORKSPACE.TAXES_SETTINGS_FOREIGN_CURRENCY_DEFAULT]: {
+ policyID: string;
+ };
+ [SCREENS.WORKSPACE.TAXES_SETTINGS_WORKSPACE_CURRENCY_DEFAULT]: {
+ policyID: string;
+ };
[SCREENS.WORKSPACE.MEMBER_DETAILS]: {
policyID: string;
accountID: string;
@@ -220,6 +232,9 @@ type SettingsNavigatorParamList = {
[SCREENS.SETTINGS.EXIT_SURVEY.CONFIRM]: {
backTo: Routes;
};
+ [SCREENS.WORKSPACE.TAX_CREATE]: {
+ policyID: string;
+ };
} & ReimbursementAccountNavigatorParamList;
type NewChatNavigatorParamList = {
@@ -281,7 +296,6 @@ type RoomInviteNavigatorParamList = {
};
type MoneyRequestNavigatorParamList = {
- [SCREENS.MONEY_REQUEST.ROOT]: undefined;
[SCREENS.MONEY_REQUEST.AMOUNT]: undefined;
[SCREENS.MONEY_REQUEST.PARTICIPANTS]: {
iouType: string;
diff --git a/src/libs/OptionsListUtils.ts b/src/libs/OptionsListUtils.ts
index e3e989dd877b..ff55343fa762 100644
--- a/src/libs/OptionsListUtils.ts
+++ b/src/libs/OptionsListUtils.ts
@@ -1202,8 +1202,8 @@ function hasEnabledTags(policyTagList: Array
* @param taxRates - The original tax rates object.
* @returns The transformed tax rates object.g
*/
-function transformedTaxRates(taxRates: TaxRatesWithDefault | undefined): Record {
- const defaultTaxKey = taxRates?.defaultExternalID;
+function transformedTaxRates(taxRates: TaxRatesWithDefault | undefined, defaultKey?: string): Record {
+ const defaultTaxKey = defaultKey ?? taxRates?.defaultExternalID;
const getModifiedName = (data: TaxRate, code: string) => `${data.name} (${data.value})${defaultTaxKey === code ? ` • ${Localize.translateLocal('common.default')}` : ''}`;
const taxes = Object.fromEntries(Object.entries(taxRates?.taxes ?? {}).map(([code, data]) => [code, {...data, code, modifiedName: getModifiedName(data, code), name: data.name}]));
return taxes;
@@ -1234,10 +1234,10 @@ function getTaxRatesOptions(taxRates: Array>): Option[] {
/**
* Builds the section list for tax rates
*/
-function getTaxRatesSection(taxRates: TaxRatesWithDefault | undefined, selectedOptions: Category[], searchInputValue: string): CategorySection[] {
+function getTaxRatesSection(taxRates: TaxRatesWithDefault | undefined, selectedOptions: Category[], searchInputValue: string, defaultTaxKey?: string): CategorySection[] {
const policyRatesSections = [];
- const taxes = transformedTaxRates(taxRates);
+ const taxes = transformedTaxRates(taxRates, defaultTaxKey);
const sortedTaxRates = sortTaxRates(taxes);
const enabledTaxRates = sortedTaxRates.filter((taxRate) => !taxRate.isDisabled);
@@ -1264,7 +1264,7 @@ function getTaxRatesSection(taxRates: TaxRatesWithDefault | undefined, selectedO
}
if (searchInputValue) {
- const searchTaxRates = enabledTaxRates.filter((taxRate) => taxRate.modifiedName.toLowerCase().includes(searchInputValue.toLowerCase()));
+ const searchTaxRates = enabledTaxRates.filter((taxRate) => taxRate.modifiedName?.toLowerCase().includes(searchInputValue.toLowerCase()));
policyRatesSections.push({
// "Search" section
@@ -1290,7 +1290,7 @@ function getTaxRatesSection(taxRates: TaxRatesWithDefault | undefined, selectedO
}
const selectedOptionNames = selectedOptions.map((selectedOption) => selectedOption.name);
- const filteredTaxRates = enabledTaxRates.filter((taxRate) => !selectedOptionNames.includes(taxRate.modifiedName));
+ const filteredTaxRates = enabledTaxRates.filter((taxRate) => taxRate.modifiedName && !selectedOptionNames.includes(taxRate.modifiedName));
if (selectedOptions.length > 0) {
const selectedTaxRatesOptions = selectedOptions.map((option) => {
@@ -2074,6 +2074,7 @@ export {
formatSectionsFromSearchTerm,
transformedTaxRates,
getShareLogOptions,
+ getTaxRatesSection,
};
-export type {MemberForList, CategorySection, GetOptions, PayeePersonalDetails};
+export type {MemberForList, CategorySection, GetOptions, PayeePersonalDetails, Category};
diff --git a/src/libs/PersonalDetailsUtils.ts b/src/libs/PersonalDetailsUtils.ts
index 65aadd440010..c8107c22bb1a 100644
--- a/src/libs/PersonalDetailsUtils.ts
+++ b/src/libs/PersonalDetailsUtils.ts
@@ -25,7 +25,13 @@ Onyx.connect({
});
function getDisplayNameOrDefault(passedPersonalDetails?: Partial | null, defaultValue = '', shouldFallbackToHidden = true, shouldAddCurrentUserPostfix = false): string {
- let displayName = passedPersonalDetails?.displayName ? passedPersonalDetails.displayName.replace(CONST.REGEX.MERGED_ACCOUNT_PREFIX, '') : '';
+ let displayName = passedPersonalDetails?.displayName ?? '';
+
+ // If the displayName starts with the merged account prefix, remove it.
+ if (displayName.startsWith(CONST.MERGED_ACCOUNT_PREFIX)) {
+ // Remove the merged account prefix from the displayName.
+ displayName = displayName.substring(CONST.MERGED_ACCOUNT_PREFIX.length);
+ }
// If the displayName is not set by the user, the backend sets the diplayName same as the login so
// we need to remove the sms domain from the displayName if it is an sms login.
@@ -37,9 +43,10 @@ function getDisplayNameOrDefault(passedPersonalDetails?: Partial): boolean
return Object.values(policyMembers ?? {}).some((member) => Object.keys(member?.errors ?? {}).length > 0);
}
+/**
+ * Check if the policy has any tax rate errors.
+ */
+function hasTaxRateError(policy: OnyxEntry): boolean {
+ return Object.values(policy?.taxRates?.taxes ?? {}).some((taxRate) => Object.keys(taxRate?.errors ?? {}).length > 0);
+}
+
/**
* Check if the policy has any errors within the categories.
*/
@@ -299,6 +306,7 @@ export {
getPathWithoutPolicyID,
getPolicyMembersByIdWithoutCurrentUser,
goBackFromInvalidPolicy,
+ hasTaxRateError,
hasPolicyCategoriesError,
};
diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts
index 3e4950bc893b..e39180db7fd0 100644
--- a/src/libs/ReportUtils.ts
+++ b/src/libs/ReportUtils.ts
@@ -794,7 +794,7 @@ function isAnnounceRoom(report: OnyxEntry): boolean {
* Whether the provided report is a default room
*/
function isDefaultRoom(report: OnyxEntry): boolean {
- return [CONST.REPORT.CHAT_TYPE.POLICY_ADMINS, CONST.REPORT.CHAT_TYPE.POLICY_ANNOUNCE, CONST.REPORT.CHAT_TYPE.DOMAIN_ALL].some((type) => type === getChatType(report));
+ return CONST.DEFAULT_POLICY_ROOM_CHAT_TYPES.some((type) => type === getChatType(report));
}
/**
diff --git a/src/libs/ValidationUtils.ts b/src/libs/ValidationUtils.ts
index 3d5f23a84f74..5876ccf5d7d7 100644
--- a/src/libs/ValidationUtils.ts
+++ b/src/libs/ValidationUtils.ts
@@ -8,7 +8,7 @@ import type {OnyxCollection} from 'react-native-onyx';
import type {FormInputErrors, FormOnyxKeys, FormOnyxValues, FormValue} from '@components/Form/types';
import CONST from '@src/CONST';
import type {OnyxFormKey} from '@src/ONYXKEYS';
-import type {Report} from '@src/types/onyx';
+import type {Report, TaxRates} from '@src/types/onyx';
import * as CardUtils from './CardUtils';
import DateUtils from './DateUtils';
import type {MaybePhraseKey} from './Localize';
@@ -460,6 +460,21 @@ function prepareValues(values: ValuesType): ValuesType {
return trimmedStringValues;
}
+/**
+ * Validates the given value if it is correct percentage value.
+ */
+function isValidPercentage(value: string): boolean {
+ const parsedValue = Number(value);
+ return !Number.isNaN(parsedValue) && parsedValue >= 0 && parsedValue <= 100;
+}
+
+/**
+ * Validates the given value if it is correct tax name.
+ */
+function isExistingTaxName(value: string, taxRates: TaxRates): boolean {
+ return !!Object.values(taxRates).find((taxRate) => taxRate.name === value);
+}
+
export {
meetsMinimumAgeRequirement,
meetsMaximumAgeRequirement,
@@ -498,4 +513,6 @@ export {
validateDateTimeIsAtLeastOneMinuteInFuture,
prepareValues,
isValidPersonName,
+ isValidPercentage,
+ isExistingTaxName,
};
diff --git a/src/libs/WorkspacesSettingsUtils.ts b/src/libs/WorkspacesSettingsUtils.ts
index a3a3d85419e2..78a8edb4c73f 100644
--- a/src/libs/WorkspacesSettingsUtils.ts
+++ b/src/libs/WorkspacesSettingsUtils.ts
@@ -5,7 +5,7 @@ import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
import type {Policy, PolicyMembers, ReimbursementAccount, Report} from '@src/types/onyx';
import * as OptionsListUtils from './OptionsListUtils';
-import {hasCustomUnitsError, hasPolicyError, hasPolicyMemberError} from './PolicyUtils';
+import {hasCustomUnitsError, hasPolicyError, hasPolicyMemberError, hasTaxRateError} from './PolicyUtils';
import * as ReportActionsUtils from './ReportActionsUtils';
import * as ReportUtils from './ReportUtils';
@@ -80,6 +80,7 @@ function hasGlobalWorkspaceSettingsRBR(policies: OnyxCollection, policyM
const errorCheckingMethods: CheckingMethod[] = [
() => Object.values(cleanPolicies).some(hasPolicyError),
() => Object.values(cleanPolicies).some(hasCustomUnitsError),
+ () => Object.values(cleanPolicies).some(hasTaxRateError),
() => Object.values(cleanAllPolicyMembers).some(hasPolicyMemberError),
() => Object.keys(reimbursementAccount?.errors ?? {}).length > 0,
];
diff --git a/src/libs/actions/IOU.ts b/src/libs/actions/IOU.ts
index af5c40836c74..868bfc28d781 100644
--- a/src/libs/actions/IOU.ts
+++ b/src/libs/actions/IOU.ts
@@ -302,7 +302,7 @@ function updateMoneyRequestTypeParams(routes: StackNavigationState, reportID: string) {
+function startMoneyRequest(iouType: ValueOf, reportID: string) {
clearMoneyRequest(CONST.IOU.OPTIMISTIC_TRANSACTION_ID);
Navigation.navigate(ROUTES.MONEY_REQUEST_CREATE.getRoute(iouType, CONST.IOU.OPTIMISTIC_TRANSACTION_ID, reportID));
}
@@ -4111,12 +4111,6 @@ function setMoneyRequestParticipantsFromReport(transactionID: string, report: On
Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transactionID}`, {participants, participantsAutoAssigned: true});
}
-/** Initialize money request info and navigate to the MoneyRequest page */
-function startMoneyRequest(iouType: string, reportID = '') {
- resetMoneyRequestInfo(`${iouType}${reportID}`);
- Navigation.navigate(ROUTES.MONEY_REQUEST.getRoute(iouType, reportID));
-}
-
function setMoneyRequestId(id: string) {
Onyx.merge(ONYXKEYS.IOU, {id});
}
@@ -4346,9 +4340,8 @@ export {
submitReport,
payMoneyRequest,
sendMoneyWithWallet,
- startMoneyRequest,
initMoneyRequest,
- startMoneyRequest_temporaryForRefactor,
+ startMoneyRequest,
resetMoneyRequestInfo,
clearMoneyRequest,
updateMoneyRequestTypeParams,
diff --git a/src/libs/actions/OnyxUpdateManager.ts b/src/libs/actions/OnyxUpdateManager.ts
index b4554f9461ce..9c6f30cc5e9e 100644
--- a/src/libs/actions/OnyxUpdateManager.ts
+++ b/src/libs/actions/OnyxUpdateManager.ts
@@ -82,7 +82,7 @@ export default () => {
previousUpdateIDFromServer,
lastUpdateIDAppliedToClient,
});
- canUnpauseQueuePromise = App.getMissingOnyxUpdates(lastUpdateIDAppliedToClient, lastUpdateIDFromServer);
+ canUnpauseQueuePromise = App.getMissingOnyxUpdates(lastUpdateIDAppliedToClient, previousUpdateIDFromServer);
}
canUnpauseQueuePromise.finally(() => {
diff --git a/src/libs/actions/Policy.ts b/src/libs/actions/Policy.ts
index bf162a59ec2f..dbbb94b82a1a 100644
--- a/src/libs/actions/Policy.ts
+++ b/src/libs/actions/Policy.ts
@@ -1142,7 +1142,6 @@ function addMembersToWorkspace(invitedEmailsToAccountIDs: InvitedEmailsToAccount
// need to remove the members since that is handled by onClose of OfflineWithFeedback.
value: failureMembersState,
},
- ...newPersonalDetailsOnyxData.finallyData,
...membersChats.onyxFailureData,
...announceRoomMembers.onyxFailureData,
];
@@ -2709,7 +2708,7 @@ function setWorkspaceCategoryEnabled(policyID: string, categoriesToUpdate: Recor
acc[key] = {
...policyCategories[key],
...categoriesToUpdate[key],
- errors: ErrorUtils.getMicroSecondOnyxError('workspace.categories.genericFailureMessage'),
+ errors: ErrorUtils.getMicroSecondOnyxError('workspace.categories.updateFailureMessage'),
pendingFields: {
enabled: null,
},
@@ -2764,7 +2763,7 @@ function createPolicyCategory(policyID: string, categoryName: string) {
key: `${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`,
value: {
[categoryName]: {
- errors: ErrorUtils.getMicroSecondOnyxError('workspace.categories.genericFailureMessage'),
+ errors: ErrorUtils.getMicroSecondOnyxError('workspace.categories.createFailureMessage'),
pendingAction: null,
},
},
@@ -2825,7 +2824,7 @@ function renamePolicyCategory(policyID: string, policyCategory: {oldName: string
...policyCategoryToUpdate,
name: policyCategory.oldName,
unencodedName: decodeURIComponent(policyCategory.oldName),
- errors: ErrorUtils.getMicroSecondOnyxError('workspace.categories.genericFailureMessage'),
+ errors: ErrorUtils.getMicroSecondOnyxError('workspace.categories.updateFailureMessage'),
pendingAction: null,
},
},
@@ -3119,7 +3118,7 @@ function setWorkspaceRequiresCategory(policyID: string, requiresCategory: boolea
key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`,
value: {
requiresCategory: !requiresCategory,
- errors: ErrorUtils.getMicroSecondOnyxError('workspace.categories.genericFailureMessage'),
+ errors: ErrorUtils.getMicroSecondOnyxError('workspace.categories.updateFailureMessage'),
pendingFields: {
requiresCategory: null,
},
@@ -3892,6 +3891,162 @@ function clearCreateDistanceRateItemAndError(policyID: string, customUnitID: str
});
}
+function setPolicyCustomTaxName(policyID: string, customTaxName: string) {
+ const policy = allPolicies?.[`${ONYXKEYS.COLLECTION.POLICY}${policyID}`];
+ const originalCustomTaxName = policy?.taxRates?.name;
+ const onyxData: OnyxData = {
+ optimisticData: [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`,
+ value: {
+ taxRates: {
+ name: customTaxName,
+ pendingFields: {name: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE},
+ errorFields: null,
+ },
+ },
+ },
+ ],
+ successData: [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`,
+ value: {
+ taxRates: {
+ pendingFields: {name: null},
+ errorFields: null,
+ },
+ },
+ },
+ ],
+ failureData: [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`,
+ value: {
+ taxRates: {
+ name: originalCustomTaxName,
+ pendingFields: {name: null},
+ errorFields: {name: ErrorUtils.getMicroSecondOnyxError('common.genericErrorMessage')},
+ },
+ },
+ },
+ ],
+ };
+
+ const parameters = {
+ policyID,
+ customTaxName,
+ };
+
+ API.write(WRITE_COMMANDS.SET_POLICY_CUSTOM_TAX_NAME, parameters, onyxData);
+}
+
+function setWorkspaceCurrencyDefault(policyID: string, taxCode: string) {
+ const policy = allPolicies?.[`${ONYXKEYS.COLLECTION.POLICY}${policyID}`];
+ const originalDefaultExternalID = policy?.taxRates?.defaultExternalID;
+ const onyxData: OnyxData = {
+ optimisticData: [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`,
+ value: {
+ taxRates: {
+ defaultExternalID: taxCode,
+ pendingFields: {defaultExternalID: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE},
+ errorFields: null,
+ },
+ },
+ },
+ ],
+ successData: [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`,
+ value: {
+ taxRates: {
+ pendingFields: {defaultExternalID: null},
+ errorFields: null,
+ },
+ },
+ },
+ ],
+ failureData: [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`,
+ value: {
+ taxRates: {
+ defaultExternalID: originalDefaultExternalID,
+ pendingFields: {defaultExternalID: null},
+ errorFields: {defaultExternalID: ErrorUtils.getMicroSecondOnyxError('common.genericErrorMessage')},
+ },
+ },
+ },
+ ],
+ };
+
+ const parameters = {
+ policyID,
+ taxCode,
+ };
+
+ API.write(WRITE_COMMANDS.SET_POLICY_TAXES_CURRENCY_DEFAULT, parameters, onyxData);
+}
+
+function setForeignCurrencyDefault(policyID: string, taxCode: string) {
+ const policy = allPolicies?.[`${ONYXKEYS.COLLECTION.POLICY}${policyID}`];
+ const originalDefaultForeignCurrencyID = policy?.taxRates?.foreignTaxDefault;
+ const onyxData: OnyxData = {
+ optimisticData: [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`,
+ value: {
+ taxRates: {
+ foreignTaxDefault: taxCode,
+ pendingFields: {foreignTaxDefault: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE},
+ errorFields: null,
+ },
+ },
+ },
+ ],
+ successData: [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`,
+ value: {
+ taxRates: {
+ pendingFields: {foreignTaxDefault: null},
+ errorFields: null,
+ },
+ },
+ },
+ ],
+ failureData: [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`,
+ value: {
+ taxRates: {
+ foreignTaxDefault: originalDefaultForeignCurrencyID,
+ pendingFields: {foreignTaxDefault: null},
+ errorFields: {foreignTaxDefault: ErrorUtils.getMicroSecondOnyxError('common.genericErrorMessage')},
+ },
+ },
+ },
+ ],
+ };
+
+ const parameters = {
+ policyID,
+ taxCode,
+ };
+
+ API.write(WRITE_COMMANDS.SET_POLICY_TAXES_FOREIGN_CURRENCY_DEFAULT, parameters, onyxData);
+}
+
export {
removeMembers,
updateWorkspaceMembersRole,
@@ -3973,4 +4128,7 @@ export {
deleteWorkspaceCategories,
deletePolicyTags,
setWorkspaceTagEnabled,
+ setWorkspaceCurrencyDefault,
+ setForeignCurrencyDefault,
+ setPolicyCustomTaxName,
};
diff --git a/src/libs/actions/Session/index.ts b/src/libs/actions/Session/index.ts
index 2c2baee9b96e..798fc2886fa8 100644
--- a/src/libs/actions/Session/index.ts
+++ b/src/libs/actions/Session/index.ts
@@ -43,7 +43,7 @@ import * as Welcome from '@userActions/Welcome';
import CONFIG from '@src/CONFIG';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
-import type {HybridAppRoute, Route as Routes} from '@src/ROUTES';
+import type {HybridAppRoute, Route} from '@src/ROUTES';
import ROUTES from '@src/ROUTES';
import SCREENS from '@src/SCREENS';
import type Credentials from '@src/types/onyx/Credentials';
@@ -915,18 +915,18 @@ function waitForUserSignIn(): Promise {
});
}
-function handleExitToNavigation(exitTo: Routes | HybridAppRoute) {
+function handleExitToNavigation(exitTo: Route | HybridAppRoute) {
InteractionManager.runAfterInteractions(() => {
waitForUserSignIn().then(() => {
Navigation.waitForProtectedRoutes().then(() => {
- const url = NativeModules.HybridAppModule ? Navigation.parseHybridAppUrl(exitTo) : exitTo;
+ const url = NativeModules.HybridAppModule ? Navigation.parseHybridAppUrl(exitTo) : (exitTo as Route);
Navigation.navigate(url);
});
});
});
}
-function signInWithValidateCodeAndNavigate(accountID: number, validateCode: string, twoFactorAuthCode = '', exitTo?: Routes | HybridAppRoute) {
+function signInWithValidateCodeAndNavigate(accountID: number, validateCode: string, twoFactorAuthCode = '', exitTo?: Route | HybridAppRoute) {
signInWithValidateCode(accountID, validateCode, twoFactorAuthCode);
if (exitTo) {
handleExitToNavigation(exitTo);
diff --git a/src/libs/actions/TaxRate.ts b/src/libs/actions/TaxRate.ts
new file mode 100644
index 000000000000..1bad1de0a9f5
--- /dev/null
+++ b/src/libs/actions/TaxRate.ts
@@ -0,0 +1,128 @@
+import Onyx from 'react-native-onyx';
+import * as API from '@libs/API';
+import type {CreatePolicyTaxParams} from '@libs/API/parameters';
+import {WRITE_COMMANDS} from '@libs/API/types';
+import CONST from '@src/CONST';
+import * as ErrorUtils from '@src/libs/ErrorUtils';
+import ONYXKEYS from '@src/ONYXKEYS';
+import type {TaxRate, TaxRates} from '@src/types/onyx';
+import type {PendingAction} from '@src/types/onyx/OnyxCommon';
+import type {OnyxData} from '@src/types/onyx/Request';
+
+/**
+ * Get tax value with percentage
+ */
+function getTaxValueWithPercentage(value: string): string {
+ return `${value}%`;
+}
+
+function covertTaxNameToID(name: string) {
+ return `id_${name.toUpperCase().replaceAll(' ', '_')}`;
+}
+
+/**
+ * Get new tax ID
+ */
+function getNextTaxCode(name: string, taxRates?: TaxRates): string {
+ const newID = covertTaxNameToID(name);
+ if (!taxRates?.[newID]) {
+ return newID;
+ }
+
+ // If the tax ID already exists, we need to find a unique ID
+ let nextID = 1;
+ while (taxRates?.[covertTaxNameToID(`${name}_${nextID}`)]) {
+ nextID++;
+ }
+ return covertTaxNameToID(`${name}_${nextID}`);
+}
+
+function createPolicyTax(policyID: string, taxRate: TaxRate) {
+ if (!taxRate.code) {
+ throw new Error('Tax code is required when creating a new tax rate.');
+ }
+
+ const onyxData: OnyxData = {
+ optimisticData: [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`,
+ value: {
+ taxRates: {
+ taxes: {
+ [taxRate.code]: {
+ ...taxRate,
+ pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD,
+ },
+ },
+ },
+ },
+ },
+ ],
+ successData: [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`,
+ value: {
+ taxRates: {
+ taxes: {
+ [taxRate.code]: {
+ errors: null,
+ pendingAction: null,
+ },
+ },
+ },
+ },
+ },
+ ],
+ failureData: [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`,
+ value: {
+ taxRates: {
+ taxes: {
+ [taxRate.code]: {
+ errors: ErrorUtils.getMicroSecondOnyxError('workspace.taxes.errors.genericFailureMessage'),
+ },
+ },
+ },
+ },
+ },
+ ],
+ };
+
+ const parameters = {
+ policyID,
+ taxFields: JSON.stringify({
+ name: taxRate.name,
+ value: taxRate.value,
+ enabled: true,
+ taxCode: taxRate.code,
+ }),
+ } satisfies CreatePolicyTaxParams;
+
+ API.write(WRITE_COMMANDS.CREATE_POLICY_TAX, parameters, onyxData);
+}
+
+function clearTaxRateError(policyID: string, taxID: string, pendingAction?: PendingAction) {
+ if (pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD) {
+ Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, {
+ taxRates: {
+ taxes: {
+ [taxID]: null,
+ },
+ },
+ });
+ return;
+ }
+ Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, {
+ taxRates: {
+ taxes: {
+ [taxID]: {pendingAction: null, errors: null},
+ },
+ },
+ });
+}
+
+export {createPolicyTax, clearTaxRateError, getNextTaxCode, getTaxValueWithPercentage};
diff --git a/src/pages/DetailsPage.tsx b/src/pages/DetailsPage.tsx
index b3b0f0782ba0..49b3e856c65d 100755
--- a/src/pages/DetailsPage.tsx
+++ b/src/pages/DetailsPage.tsx
@@ -101,7 +101,6 @@ function DetailsPage({personalDetails, route, session}: DetailsPageProps) {
diff --git a/src/pages/LogInWithShortLivedAuthTokenPage.tsx b/src/pages/LogInWithShortLivedAuthTokenPage.tsx
index d002b87b15fa..72425e0e2ca6 100644
--- a/src/pages/LogInWithShortLivedAuthTokenPage.tsx
+++ b/src/pages/LogInWithShortLivedAuthTokenPage.tsx
@@ -18,6 +18,7 @@ import type {PublicScreensParamList} from '@libs/Navigation/types';
import * as Session from '@userActions/Session';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
+import type {Route} from '@src/ROUTES';
import ROUTES from '@src/ROUTES';
import type SCREENS from '@src/SCREENS';
import type {Account} from '@src/types/onyx';
@@ -63,7 +64,7 @@ function LogInWithShortLivedAuthTokenPage({route, account}: LogInWithShortLivedA
if (exitTo) {
Navigation.isNavigationReady().then(() => {
- const url = NativeModules.HybridAppModule ? Navigation.parseHybridAppUrl(exitTo) : exitTo;
+ const url = NativeModules.HybridAppModule ? Navigation.parseHybridAppUrl(exitTo) : (exitTo as Route);
Navigation.navigate(url);
});
}
diff --git a/src/pages/home/ReportScreen.tsx b/src/pages/home/ReportScreen.tsx
index 4e12c75248d3..a054716a02b3 100644
--- a/src/pages/home/ReportScreen.tsx
+++ b/src/pages/home/ReportScreen.tsx
@@ -98,6 +98,20 @@ function getReportID(route: ReportScreenNavigationProps['route']): string {
return String(route.params?.reportID || 0);
}
+/**
+ * Check is the report is deleted.
+ * We currently use useMemo to memorize every properties of the report
+ * so we can't check using isEmpty.
+ *
+ * @param report
+ */
+function isEmpty(report: OnyxTypes.Report): boolean {
+ if (isEmptyObject(report)) {
+ return true;
+ }
+ return !Object.values(report).some((value) => value !== undefined && value !== '');
+}
+
function ReportScreen({
betas = [],
route,
@@ -394,7 +408,7 @@ function ReportScreen({
!onyxReportID &&
prevReport.statusNum === CONST.REPORT.STATUS_NUM.OPEN &&
(report.statusNum === CONST.REPORT.STATUS_NUM.CLOSED || (!report.statusNum && !prevReport.parentReportID && prevReport.chatType === CONST.REPORT.CHAT_TYPE.POLICY_ROOM))) ||
- ((ReportUtils.isMoneyRequest(prevReport) || ReportUtils.isMoneyRequestReport(prevReport)) && isEmptyObject(report))
+ ((ReportUtils.isMoneyRequest(prevReport) || ReportUtils.isMoneyRequestReport(prevReport) || ReportUtils.isPolicyExpenseChat(prevReport)) && isEmpty(report))
) {
Navigation.dismissModal();
if (Navigation.getTopmostReportId() === prevOnyxReportID) {
diff --git a/src/pages/home/report/ReportActionCompose/AttachmentPickerWithMenuItems.tsx b/src/pages/home/report/ReportActionCompose/AttachmentPickerWithMenuItems.tsx
index 68c7f0883683..6a21845f47ad 100644
--- a/src/pages/home/report/ReportActionCompose/AttachmentPickerWithMenuItems.tsx
+++ b/src/pages/home/report/ReportActionCompose/AttachmentPickerWithMenuItems.tsx
@@ -124,17 +124,17 @@ function AttachmentPickerWithMenuItems({
[CONST.IOU.TYPE.SPLIT]: {
icon: Expensicons.Receipt,
text: translate('iou.splitBill'),
- onSelected: () => IOU.startMoneyRequest_temporaryForRefactor(CONST.IOU.TYPE.SPLIT, report?.reportID ?? ''),
+ onSelected: () => IOU.startMoneyRequest(CONST.IOU.TYPE.SPLIT, report?.reportID ?? ''),
},
[CONST.IOU.TYPE.REQUEST]: {
icon: Expensicons.MoneyCircle,
text: translate('iou.requestMoney'),
- onSelected: () => IOU.startMoneyRequest_temporaryForRefactor(CONST.IOU.TYPE.REQUEST, report?.reportID ?? ''),
+ onSelected: () => IOU.startMoneyRequest(CONST.IOU.TYPE.REQUEST, report?.reportID ?? ''),
},
[CONST.IOU.TYPE.SEND]: {
icon: Expensicons.Send,
text: translate('iou.sendMoney'),
- onSelected: () => IOU.startMoneyRequest(CONST.IOU.TYPE.SEND, report?.reportID),
+ onSelected: () => IOU.startMoneyRequest(CONST.IOU.TYPE.SEND, report?.reportID ?? ''),
},
};
diff --git a/src/pages/home/report/ReportActionCompose/SuggestionMention.tsx b/src/pages/home/report/ReportActionCompose/SuggestionMention.tsx
index 5f07dc66ea4d..e447bed67514 100644
--- a/src/pages/home/report/ReportActionCompose/SuggestionMention.tsx
+++ b/src/pages/home/report/ReportActionCompose/SuggestionMention.tsx
@@ -56,6 +56,16 @@ function SuggestionMention(
shouldExcludeTextAreaNodes: false,
});
+ // Used to store the selection index of the last inserted mention
+ const suggestionInsertionIndexRef = useRef(null);
+
+ // Used to detect if the selection has changed since the last suggestion insertion
+ // If so, we reset the suggestionInsertionIndexRef
+ const hasSelectionChanged = !(selection.end === selection.start && selection.start === suggestionInsertionIndexRef.current);
+ if (hasSelectionChanged) {
+ suggestionInsertionIndexRef.current = null;
+ }
+
// Used to decide whether to block the suggestions list from showing to prevent flickering
const shouldBlockCalc = useRef(false);
@@ -89,10 +99,12 @@ function SuggestionMention(
const commentAfterMention = value.slice(suggestionValues.atSignIndex + suggestionValues.mentionPrefix.length + 1);
updateComment(`${commentBeforeAtSign}${mentionCode} ${SuggestionsUtils.trimLeadingSpace(commentAfterMention)}`, true);
+ const selectionPosition = suggestionValues.atSignIndex + mentionCode.length + CONST.SPACE_LENGTH;
setSelection({
- start: suggestionValues.atSignIndex + mentionCode.length + CONST.SPACE_LENGTH,
- end: suggestionValues.atSignIndex + mentionCode.length + CONST.SPACE_LENGTH,
+ start: selectionPosition,
+ end: selectionPosition,
});
+ suggestionInsertionIndexRef.current = selectionPosition;
setSuggestionValues((prevState) => ({
...prevState,
suggestedMentions: [],
@@ -167,6 +179,14 @@ function SuggestionMention(
if (searchValue && !displayText.toLowerCase().includes(searchValue.toLowerCase())) {
return false;
}
+
+ // Given the mention is inserted by user, we don't want to show the mention options unless the
+ // selection index changes. In that case, suggestionInsertionIndexRef.current will be null.
+ // See https://github.com/Expensify/App/issues/38358 for more context
+ if (suggestionInsertionIndexRef.current) {
+ return false;
+ }
+
return true;
});
@@ -247,7 +267,6 @@ function SuggestionMention(
if (!isCursorBeforeTheMention && isMentionCode(suggestionWord)) {
const suggestions = getMentionOptions(personalDetails, prefix);
-
nextState.suggestedMentions = suggestions;
nextState.shouldShowSuggestionMenu = !!suggestions.length;
}
@@ -268,7 +287,6 @@ function SuggestionMention(
// See: https://github.com/facebook/react-native/pull/36930#issuecomment-1593028467
return;
}
-
calculateMentionSuggestion(selection.end);
}, [selection, value, previousValue, calculateMentionSuggestion]);
diff --git a/src/pages/home/sidebar/AvatarWithOptionalStatus.js b/src/pages/home/sidebar/AvatarWithOptionalStatus.js
index 942d5c1da1f2..039a3c78503a 100644
--- a/src/pages/home/sidebar/AvatarWithOptionalStatus.js
+++ b/src/pages/home/sidebar/AvatarWithOptionalStatus.js
@@ -2,12 +2,9 @@
import PropTypes from 'prop-types';
import React from 'react';
import {View} from 'react-native';
-import PressableWithoutFeedback from '@components/Pressable/PressableWithoutFeedback';
import Text from '@components/Text';
-import useLocalize from '@hooks/useLocalize';
import useThemeStyles from '@hooks/useThemeStyles';
-import CONST from '@src/CONST';
-import PressableAvatarWithIndicator from './PressableAvatarWithIndicator';
+import ProfileAvatarWithIndicator from './ProfileAvatarWithIndicator';
const propTypes = {
/** Emoji status */
@@ -15,40 +12,27 @@ const propTypes = {
/** Whether the avatar is selected */
isSelected: PropTypes.bool,
-
- /** Callback called when the avatar or status icon is pressed */
- onPress: PropTypes.func,
};
const defaultProps = {
emojiStatus: '',
isSelected: false,
- onPress: () => {},
};
-function AvatarWithOptionalStatus({emojiStatus, isSelected, onPress}) {
+function AvatarWithOptionalStatus({emojiStatus, isSelected}) {
const styles = useThemeStyles();
- const {translate} = useLocalize();
return (
-
-
+
+
{emojiStatus}
-
+
);
}
diff --git a/src/pages/home/sidebar/BottomTabAvatar.tsx b/src/pages/home/sidebar/BottomTabAvatar.tsx
index 15134b762161..63abcf063b5f 100644
--- a/src/pages/home/sidebar/BottomTabAvatar.tsx
+++ b/src/pages/home/sidebar/BottomTabAvatar.tsx
@@ -1,11 +1,16 @@
/* eslint-disable rulesdir/onyx-props-must-have-default */
import React, {useCallback} from 'react';
+import {PressableWithFeedback} from '@components/Pressable';
+import Tooltip from '@components/Tooltip';
import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails';
+import useLocalize from '@hooks/useLocalize';
+import useThemeStyles from '@hooks/useThemeStyles';
import interceptAnonymousUser from '@libs/interceptAnonymousUser';
import Navigation from '@libs/Navigation/Navigation';
+import CONST from '@src/CONST';
import ROUTES from '@src/ROUTES';
import AvatarWithOptionalStatus from './AvatarWithOptionalStatus';
-import PressableAvatarWithIndicator from './PressableAvatarWithIndicator';
+import ProfileAvatarWithIndicator from './ProfileAvatarWithIndicator';
type BottomTabAvatarProps = {
/** Whether the create menu is open or not */
@@ -16,6 +21,8 @@ type BottomTabAvatarProps = {
};
function BottomTabAvatar({isCreateMenuOpen = false, isSelected = false}: BottomTabAvatarProps) {
+ const styles = useThemeStyles();
+ const {translate} = useLocalize();
const currentUserPersonalDetails = useCurrentUserPersonalDetails();
const emojiStatus = currentUserPersonalDetails?.status?.emojiCode ?? '';
@@ -28,20 +35,31 @@ function BottomTabAvatar({isCreateMenuOpen = false, isSelected = false}: BottomT
interceptAnonymousUser(() => Navigation.navigate(ROUTES.SETTINGS));
}, [isCreateMenuOpen]);
+ let children;
+
if (emojiStatus) {
- return (
+ children = (
);
+ } else {
+ children = ;
}
+
return (
-
+
+
+ {children}
+
+
);
}
diff --git a/src/pages/home/sidebar/PressableAvatarWithIndicator.js b/src/pages/home/sidebar/PressableAvatarWithIndicator.js
deleted file mode 100644
index a7345ff6c14a..000000000000
--- a/src/pages/home/sidebar/PressableAvatarWithIndicator.js
+++ /dev/null
@@ -1,78 +0,0 @@
-/* eslint-disable rulesdir/onyx-props-must-have-default */
-import lodashGet from 'lodash/get';
-import PropTypes from 'prop-types';
-import React from 'react';
-import {View} from 'react-native';
-import {withOnyx} from 'react-native-onyx';
-import AvatarWithIndicator from '@components/AvatarWithIndicator';
-import OfflineWithFeedback from '@components/OfflineWithFeedback';
-import PressableWithoutFeedback from '@components/Pressable/PressableWithoutFeedback';
-import withCurrentUserPersonalDetails from '@components/withCurrentUserPersonalDetails';
-import useLocalize from '@hooks/useLocalize';
-import useThemeStyles from '@hooks/useThemeStyles';
-import compose from '@libs/compose';
-import * as UserUtils from '@libs/UserUtils';
-import personalDetailsPropType from '@pages/personalDetailsPropType';
-import CONST from '@src/CONST';
-import ONYXKEYS from '@src/ONYXKEYS';
-
-const propTypes = {
- /** The personal details of the person who is logged in */
- currentUserPersonalDetails: personalDetailsPropType,
-
- /** Indicates whether the app is loading initial data */
- isLoading: PropTypes.bool,
-
- /** Whether the avatar is selected */
- isSelected: PropTypes.bool,
-
- /** Callback called when the avatar is pressed */
- onPress: PropTypes.func,
-};
-
-const defaultProps = {
- currentUserPersonalDetails: {
- pendingFields: {avatar: ''},
- accountID: '',
- avatar: '',
- },
- isLoading: true,
- isSelected: false,
- onPress: () => {},
-};
-
-function PressableAvatarWithIndicator({currentUserPersonalDetails, isLoading, isSelected, onPress}) {
- const {translate} = useLocalize();
- const styles = useThemeStyles();
-
- return (
-
-
-
-
-
-
-
- );
-}
-
-PressableAvatarWithIndicator.propTypes = propTypes;
-PressableAvatarWithIndicator.defaultProps = defaultProps;
-PressableAvatarWithIndicator.displayName = 'PressableAvatarWithIndicator';
-export default compose(
- withCurrentUserPersonalDetails,
- withOnyx({
- isLoading: {
- key: ONYXKEYS.IS_LOADING_APP,
- },
- }),
-)(PressableAvatarWithIndicator);
diff --git a/src/pages/home/sidebar/ProfileAvatarWithIndicator.js b/src/pages/home/sidebar/ProfileAvatarWithIndicator.js
new file mode 100644
index 000000000000..bd9c01aba001
--- /dev/null
+++ b/src/pages/home/sidebar/ProfileAvatarWithIndicator.js
@@ -0,0 +1,63 @@
+/* eslint-disable rulesdir/onyx-props-must-have-default */
+import lodashGet from 'lodash/get';
+import PropTypes from 'prop-types';
+import React from 'react';
+import {View} from 'react-native';
+import {withOnyx} from 'react-native-onyx';
+import AvatarWithIndicator from '@components/AvatarWithIndicator';
+import OfflineWithFeedback from '@components/OfflineWithFeedback';
+import withCurrentUserPersonalDetails from '@components/withCurrentUserPersonalDetails';
+import useThemeStyles from '@hooks/useThemeStyles';
+import compose from '@libs/compose';
+import * as UserUtils from '@libs/UserUtils';
+import personalDetailsPropType from '@pages/personalDetailsPropType';
+import ONYXKEYS from '@src/ONYXKEYS';
+
+const propTypes = {
+ /** The personal details of the person who is logged in */
+ currentUserPersonalDetails: personalDetailsPropType,
+
+ /** Indicates whether the app is loading initial data */
+ isLoading: PropTypes.bool,
+
+ /** Whether the avatar is selected */
+ isSelected: PropTypes.bool,
+};
+
+const defaultProps = {
+ currentUserPersonalDetails: {
+ pendingFields: {avatar: ''},
+ accountID: '',
+ avatar: '',
+ },
+ isLoading: true,
+ isSelected: false,
+};
+
+function ProfileAvatarWithIndicator({currentUserPersonalDetails, isLoading, isSelected}) {
+ const styles = useThemeStyles();
+
+ return (
+
+
+
+
+
+ );
+}
+
+ProfileAvatarWithIndicator.propTypes = propTypes;
+ProfileAvatarWithIndicator.defaultProps = defaultProps;
+ProfileAvatarWithIndicator.displayName = 'ProfileAvatarWithIndicator';
+export default compose(
+ withCurrentUserPersonalDetails,
+ withOnyx({
+ isLoading: {
+ key: ONYXKEYS.IS_LOADING_APP,
+ },
+ }),
+)(ProfileAvatarWithIndicator);
diff --git a/src/pages/home/sidebar/SidebarScreen/FloatingActionButtonAndPopover.js b/src/pages/home/sidebar/SidebarScreen/FloatingActionButtonAndPopover.js
index 573cbe370aa7..ec27112ab4b7 100644
--- a/src/pages/home/sidebar/SidebarScreen/FloatingActionButtonAndPopover.js
+++ b/src/pages/home/sidebar/SidebarScreen/FloatingActionButtonAndPopover.js
@@ -166,7 +166,7 @@ function FloatingActionButtonAndPopover(props) {
text: translate('iou.requestMoney'),
onSelected: () =>
interceptAnonymousUser(() =>
- IOU.startMoneyRequest_temporaryForRefactor(
+ IOU.startMoneyRequest(
CONST.IOU.TYPE.REQUEST,
// When starting to create a money request from the global FAB, there is not an existing report yet. A random optimistic reportID is generated and used
// for all of the routes in the creation flow.
@@ -177,7 +177,15 @@ function FloatingActionButtonAndPopover(props) {
{
icon: Expensicons.Send,
text: translate('iou.sendMoney'),
- onSelected: () => interceptAnonymousUser(() => IOU.startMoneyRequest(CONST.IOU.TYPE.SEND)),
+ onSelected: () =>
+ interceptAnonymousUser(() =>
+ IOU.startMoneyRequest(
+ CONST.IOU.TYPE.SEND,
+ // When starting to create a send money request from the global FAB, there is not an existing report yet. A random optimistic reportID is generated and used
+ // for all of the routes in the creation flow.
+ ReportUtils.generateReportID(),
+ ),
+ ),
},
...[
{
diff --git a/src/pages/iou/IOUCurrencySelection.js b/src/pages/iou/IOUCurrencySelection.js
index 7495efb43171..c1071a333aac 100644
--- a/src/pages/iou/IOUCurrencySelection.js
+++ b/src/pages/iou/IOUCurrencySelection.js
@@ -18,7 +18,6 @@ import * as ReportActionsUtils from '@libs/ReportActionsUtils';
import * as ReportUtils from '@libs/ReportUtils';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
-import ROUTES from '@src/ROUTES';
import {iouDefaultProps, iouPropTypes} from './propTypes';
/**
@@ -72,9 +71,8 @@ function IOUCurrencySelection(props) {
const [searchValue, setSearchValue] = useState('');
const optionsSelectorRef = useRef();
const selectedCurrencyCode = (lodashGet(props.route, 'params.currency', props.iou.currency) || CONST.CURRENCY.USD).toUpperCase();
- const iouType = lodashGet(props.route, 'params.iouType', CONST.IOU.TYPE.REQUEST);
- const reportID = lodashGet(props.route, 'params.reportID', '');
const threadReportID = lodashGet(props.route, 'params.threadReportID', '');
+ const backTo = lodashGet(props.route, 'params.backTo', '');
// Decides whether to allow or disallow editing a money request
useEffect(() => {
@@ -99,7 +97,6 @@ function IOUCurrencySelection(props) {
const confirmCurrencySelection = useCallback(
(option) => {
- const backTo = lodashGet(props.route, 'params.backTo', '');
Keyboard.dismiss();
// When we refresh the web, the money request route gets cleared from the navigation stack.
@@ -111,7 +108,7 @@ function IOUCurrencySelection(props) {
Navigation.navigate(`${props.route.params.backTo}?currency=${option.currencyCode}`);
}
},
- [props.route, props.navigation],
+ [props.route, props.navigation, backTo],
);
const {translate, currencyList} = props;
@@ -162,7 +159,7 @@ function IOUCurrencySelection(props) {
<>
Navigation.goBack(ROUTES.MONEY_REQUEST.getRoute(iouType, reportID))}
+ onBackButtonPress={() => Navigation.goBack(backTo)}
/>
{
- const moneyRequestID = `${iouType}${reportID}`;
- IOU.resetMoneyRequestInfo(moneyRequestID);
- };
-
- // Allow the user to create the request if we are creating the request in global menu or the report can create the request
- const isAllowedToCreateRequest = _.isEmpty(props.report.reportID) || ReportUtils.canCreateRequest(props.report, props.policy, iouType);
- const prevSelectedTab = usePrevious(props.selectedTab);
-
- useEffect(() => {
- if (prevSelectedTab === props.selectedTab) {
- return;
- }
-
- resetMoneyRequestInfo();
- // resetMoneyRequestInfo function is not added as dependencies since they don't change between renders
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, [props.selectedTab, prevSelectedTab]);
-
- return (
-
- {({safeAreaPaddingBottomStyle}) => (
-
-
-
-
- {iouType === CONST.IOU.TYPE.REQUEST || iouType === CONST.IOU.TYPE.SPLIT ? (
-
-
- {() => }
- {shouldDisplayDistanceRequest && (
-
- )}
-
- ) : (
-
- )}
-
-
-
- )}
-
- );
-}
-
-MoneyRequestSelectorPage.propTypes = propTypes;
-MoneyRequestSelectorPage.defaultProps = defaultProps;
-MoneyRequestSelectorPage.displayName = 'MoneyRequestSelectorPage';
-
-export default compose(
- withReportOrNotFound(false),
- withOnyx({
- selectedTab: {
- key: `${ONYXKEYS.COLLECTION.SELECTED_TAB}${CONST.TAB.RECEIPT_TAB_ID}`,
- },
- policy: {
- key: ({report}) => `${ONYXKEYS.COLLECTION.POLICY}${lodashGet(report, 'policyID')}`,
- },
- }),
-)(MoneyRequestSelectorPage);
diff --git a/src/pages/iou/request/IOURequestStartPage.js b/src/pages/iou/request/IOURequestStartPage.js
index b1ae257b792f..589808824285 100644
--- a/src/pages/iou/request/IOURequestStartPage.js
+++ b/src/pages/iou/request/IOURequestStartPage.js
@@ -157,16 +157,20 @@ function IOURequestStartPage({
title={tabTitles[iouType]}
onBackButtonPress={navigateBack}
/>
-
- {() => }
- {() => }
- {shouldDisplayDistanceRequest && {() => }}
-
+ {iouType === CONST.IOU.TYPE.REQUEST || iouType === CONST.IOU.TYPE.SPLIT ? (
+
+ {() => }
+ {() => }
+ {shouldDisplayDistanceRequest && {() => }}
+
+ ) : (
+
+ )}
diff --git a/src/pages/iou/request/MoneyTemporaryForRefactorRequestParticipantsSelector.js b/src/pages/iou/request/MoneyTemporaryForRefactorRequestParticipantsSelector.js
index f9317a1ea964..cdc3e72f98f4 100644
--- a/src/pages/iou/request/MoneyTemporaryForRefactorRequestParticipantsSelector.js
+++ b/src/pages/iou/request/MoneyTemporaryForRefactorRequestParticipantsSelector.js
@@ -255,7 +255,7 @@ function MoneyTemporaryForRefactorRequestParticipantsSelector({
// the app from crashing on native when you try to do this, we'll going to hide the button if you have a workspace and other participants
const hasPolicyExpenseChatParticipant = _.some(participants, (participant) => participant.isPolicyExpenseChat);
const shouldShowSplitBillErrorMessage = participants.length > 1 && hasPolicyExpenseChatParticipant;
- const isAllowedToSplit = canUseP2PDistanceRequests || iouRequestType !== CONST.IOU.REQUEST_TYPE.DISTANCE;
+ const isAllowedToSplit = (canUseP2PDistanceRequests || iouRequestType !== CONST.IOU.REQUEST_TYPE.DISTANCE) && iouType !== CONST.IOU.TYPE.SEND;
const handleConfirmSelection = useCallback(() => {
if (shouldShowSplitBillErrorMessage) {
diff --git a/src/pages/iou/request/step/IOURequestStepConfirmation.js b/src/pages/iou/request/step/IOURequestStepConfirmation.js
index 9904f64d6833..0a6a4cc610a9 100644
--- a/src/pages/iou/request/step/IOURequestStepConfirmation.js
+++ b/src/pages/iou/request/step/IOURequestStepConfirmation.js
@@ -96,7 +96,16 @@ function IOURequestStepConfirmation({
const transactionTaxCode = transaction.taxRate && transaction.taxRate.keyForList;
const transactionTaxAmount = transaction.taxAmount;
const requestType = TransactionUtils.getRequestType(transaction);
- const headerTitle = iouType === CONST.IOU.TYPE.SPLIT ? translate('iou.split') : translate(TransactionUtils.getHeaderTitleTranslationKey(transaction));
+ const headerTitle = useMemo(() => {
+ if (iouType === CONST.IOU.TYPE.SPLIT) {
+ return translate('iou.split');
+ }
+ if (iouType === CONST.IOU.TYPE.SEND) {
+ return translate('common.send');
+ }
+ return translate(TransactionUtils.getHeaderTitleTranslationKey(transaction));
+ }, [iouType, transaction, translate]);
+
const participants = useMemo(
() =>
_.map(transaction.participants, (participant) => {
@@ -357,7 +366,9 @@ function IOURequestStepConfirmation({
const sendMoney = useCallback(
(paymentMethodType) => {
const currency = transaction.currency;
- const trimmedComment = transaction.comment.trim();
+
+ const trimmedComment = transaction.comment && transaction.comment.comment ? transaction.comment.comment.trim() : '';
+
const participant = participants[0];
if (paymentMethodType === CONST.IOU.PAYMENT_TYPE.ELSEWHERE) {
@@ -369,8 +380,9 @@ function IOURequestStepConfirmation({
IOU.sendMoneyWithWallet(report, transaction.amount, currency, trimmedComment, currentUserPersonalDetails.accountID, participant);
}
},
- [transaction.amount, transaction.comment, participants, transaction.currency, currentUserPersonalDetails.accountID, report],
+ [transaction.amount, transaction.comment, transaction.currency, participants, currentUserPersonalDetails.accountID, report],
);
+
const addNewParticipant = (option) => {
const newParticipants = _.map(transaction.participants, (participant) => {
if (participant.accountID === option.accountID) {
diff --git a/src/pages/iou/request/step/IOURequestStepParticipants.js b/src/pages/iou/request/step/IOURequestStepParticipants.js
index b2f5cbb68cd1..5ca465d8fb78 100644
--- a/src/pages/iou/request/step/IOURequestStepParticipants.js
+++ b/src/pages/iou/request/step/IOURequestStepParticipants.js
@@ -1,6 +1,6 @@
import {useNavigation} from '@react-navigation/native';
import lodashGet from 'lodash/get';
-import React, {useCallback, useEffect, useRef} from 'react';
+import React, {useCallback, useEffect, useMemo, useRef} from 'react';
import _ from 'underscore';
import transactionPropTypes from '@components/transactionPropTypes';
import useLocalize from '@hooks/useLocalize';
@@ -43,7 +43,16 @@ function IOURequestStepParticipants({
const numberOfParticipants = useRef(participants.length);
const iouRequestType = TransactionUtils.getRequestType(transaction);
const isSplitRequest = iouType === CONST.IOU.TYPE.SPLIT;
- const headerTitle = isSplitRequest ? translate('iou.split') : translate(TransactionUtils.getHeaderTitleTranslationKey(transaction));
+ const headerTitle = useMemo(() => {
+ if (isSplitRequest) {
+ return translate('iou.split');
+ }
+ if (iouType === CONST.IOU.TYPE.SEND) {
+ return translate('common.send');
+ }
+ return translate(TransactionUtils.getHeaderTitleTranslationKey(transaction));
+ }, [iouType, transaction, translate, isSplitRequest]);
+
const receiptFilename = lodashGet(transaction, 'filename');
const receiptPath = lodashGet(transaction, 'receipt.source');
const receiptType = lodashGet(transaction, 'receipt.type');
@@ -109,7 +118,14 @@ function IOURequestStepParticipants({
const goToNextStep = useCallback(
(selectedIouType) => {
const isSplit = selectedIouType === CONST.IOU.TYPE.SPLIT;
- const nextStepIOUType = !isSplit && iouType !== CONST.IOU.TYPE.REQUEST ? CONST.IOU.TYPE.REQUEST : iouType;
+ let nextStepIOUType = CONST.IOU.TYPE.REQUEST;
+
+ if (isSplit && iouType !== CONST.IOU.TYPE.REQUEST) {
+ nextStepIOUType = CONST.IOU.TYPE.SPLIT;
+ } else if (iouType === CONST.IOU.TYPE.SEND) {
+ nextStepIOUType = CONST.IOU.TYPE.SEND;
+ }
+
IOU.setMoneyRequestTag(transactionID, '');
IOU.setMoneyRequestCategory(transactionID, '');
Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_CONFIRMATION.getRoute(nextStepIOUType, transactionID, selectedReportID.current || reportID));
diff --git a/src/pages/iou/request/step/IOURequestStepTaxRatePage.js b/src/pages/iou/request/step/IOURequestStepTaxRatePage.js
index 0cc2375b5510..335964adf309 100644
--- a/src/pages/iou/request/step/IOURequestStepTaxRatePage.js
+++ b/src/pages/iou/request/step/IOURequestStepTaxRatePage.js
@@ -59,8 +59,7 @@ function IOURequestStepTaxRatePage({
};
const taxRates = lodashGet(policy, 'taxRates', {});
const defaultTaxKey = taxRates.defaultExternalID;
- const defaultTaxName = (defaultTaxKey && `${taxRates.taxes[defaultTaxKey].name} (${taxRates.taxes[defaultTaxKey].value}) • ${translate('common.default')}`) || '';
- const selectedTaxRate = (transaction.taxRate && transaction.taxRate.text) || defaultTaxName;
+ const selectedTaxRate = (transaction.taxRate && transaction.taxRate.keyForList) || defaultTaxKey;
const updateTaxRates = (taxes) => {
const taxAmount = getTaxAmount(taxRates, taxes.text, transaction.amount);
diff --git a/src/pages/iou/request/step/IOURequestStepWaypoint.tsx b/src/pages/iou/request/step/IOURequestStepWaypoint.tsx
index 5a68c85546e6..a095b2418b82 100644
--- a/src/pages/iou/request/step/IOURequestStepWaypoint.tsx
+++ b/src/pages/iou/request/step/IOURequestStepWaypoint.tsx
@@ -131,13 +131,13 @@ function IOURequestStepWaypoint({
}
// Other flows will be handled by selecting a waypoint with selectWaypoint as this is mainly for the offline flow
- Navigation.goBack(ROUTES.MONEY_REQUEST_DISTANCE_TAB.getRoute(iouType));
+ Navigation.goBack(ROUTES.MONEY_REQUEST_STEP_DISTANCE.getRoute(iouType, transactionID, reportID));
};
const deleteStopAndHideModal = () => {
Transaction.removeWaypoint(transaction, pageIndex, true);
setIsDeleteStopModalOpen(false);
- Navigation.goBack(ROUTES.MONEY_REQUEST_DISTANCE_TAB.getRoute(iouType));
+ Navigation.goBack(ROUTES.MONEY_REQUEST_STEP_DISTANCE.getRoute(iouType, transactionID, reportID));
};
const selectWaypoint = (values: Waypoint) => {
@@ -168,7 +168,7 @@ function IOURequestStepWaypoint({
title={translate(waypointDescriptionKey)}
shouldShowBackButton
onBackButtonPress={() => {
- Navigation.goBack(ROUTES.MONEY_REQUEST_DISTANCE_TAB.getRoute(iouType));
+ Navigation.goBack(ROUTES.MONEY_REQUEST_STEP_DISTANCE.getRoute(iouType, transactionID, reportID));
}}
shouldShowThreeDotsButton={shouldShowThreeDotsButton}
shouldSetModalVisibility={false}
diff --git a/src/pages/workspace/WorkspaceInitialPage.tsx b/src/pages/workspace/WorkspaceInitialPage.tsx
index 871bb2cf7980..9a56a2da0314 100644
--- a/src/pages/workspace/WorkspaceInitialPage.tsx
+++ b/src/pages/workspace/WorkspaceInitialPage.tsx
@@ -194,6 +194,7 @@ function WorkspaceInitialPage({policyDraft, policy: policyProp, policyMembers, r
icon: Expensicons.Tax,
action: singleExecution(waitForNavigate(() => Navigation.navigate(ROUTES.WORKSPACE_TAXES.getRoute(policyID)))),
routeName: SCREENS.WORKSPACE.TAXES,
+ brickRoadIndicator: PolicyUtils.hasTaxRateError(policy) ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined,
});
}
diff --git a/src/pages/workspace/categories/CategoryForm.tsx b/src/pages/workspace/categories/CategoryForm.tsx
index aaf954f64468..304f4153df8c 100644
--- a/src/pages/workspace/categories/CategoryForm.tsx
+++ b/src/pages/workspace/categories/CategoryForm.tsx
@@ -80,7 +80,6 @@ function CategoryForm({onSubmit, policyCategories, categoryName}: CategoryFormPr
accessibilityLabel={translate('common.name')}
inputID={INPUT_IDS.CATEGORY_NAME}
role={CONST.ROLE.PRESENTATION}
- autoFocus
/>
);
diff --git a/src/pages/workspace/categories/CategorySettingsPage.tsx b/src/pages/workspace/categories/CategorySettingsPage.tsx
index 7300f7c5a9d4..1fa3fdcdd042 100644
--- a/src/pages/workspace/categories/CategorySettingsPage.tsx
+++ b/src/pages/workspace/categories/CategorySettingsPage.tsx
@@ -1,9 +1,11 @@
import type {StackScreenProps} from '@react-navigation/stack';
-import React from 'react';
+import React, {useState} from 'react';
import {View} from 'react-native';
import {withOnyx} from 'react-native-onyx';
import type {OnyxEntry} from 'react-native-onyx';
+import ConfirmModal from '@components/ConfirmModal';
import HeaderWithBackButton from '@components/HeaderWithBackButton';
+import * as Expensicons from '@components/Icon/Expensicons';
import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription';
import OfflineWithFeedback from '@components/OfflineWithFeedback';
import ScreenWrapper from '@components/ScreenWrapper';
@@ -11,6 +13,7 @@ import Switch from '@components/Switch';
import Text from '@components/Text';
import useLocalize from '@hooks/useLocalize';
import useThemeStyles from '@hooks/useThemeStyles';
+import useWindowDimensions from '@hooks/useWindowDimensions';
import {setWorkspaceCategoryEnabled} from '@libs/actions/Policy';
import * as ErrorUtils from '@libs/ErrorUtils';
import Navigation from '@libs/Navigation/Navigation';
@@ -34,6 +37,8 @@ type CategorySettingsPageProps = CategorySettingsPageOnyxProps & StackScreenProp
function CategorySettingsPage({route, policyCategories}: CategorySettingsPageProps) {
const styles = useThemeStyles();
const {translate} = useLocalize();
+ const {windowWidth} = useWindowDimensions();
+ const [deleteCategoryConfirmModalVisible, setDeleteCategoryConfirmModalVisible] = useState(false);
const policyCategory = policyCategories?.[route.params.categoryName];
@@ -49,6 +54,20 @@ function CategorySettingsPage({route, policyCategories}: CategorySettingsPagePro
Navigation.navigate(ROUTES.WORKSPACE_CATEGORY_EDIT.getRoute(route.params.policyID, policyCategory.name));
};
+ const deleteCategory = () => {
+ Policy.deleteWorkspaceCategories(route.params.policyID, [route.params.categoryName]);
+ setDeleteCategoryConfirmModalVisible(false);
+ Navigation.dismissModal();
+ };
+
+ const threeDotsMenuItems = [
+ {
+ icon: Expensicons.Trashcan,
+ text: translate('workspace.categories.deleteCategory'),
+ onSelected: () => setDeleteCategoryConfirmModalVisible(true),
+ },
+ ];
+
return (
@@ -57,7 +76,22 @@ function CategorySettingsPage({route, policyCategories}: CategorySettingsPagePro
style={[styles.defaultModalContainer]}
testID={CategorySettingsPage.displayName}
>
-
+
+ setDeleteCategoryConfirmModalVisible(false)}
+ title={translate('workspace.categories.deleteCategory')}
+ prompt={translate('workspace.categories.deleteCategoryPrompt')}
+ confirmText={translate('common.delete')}
+ cancelText={translate('common.cancel')}
+ danger
+ />
Navigation.goBack(ROUTES.WORKSPACE_CATEGORY_SETTINGS.getRoute(route.params.policyID, route.params.categoryName))}
/>
>({});
const dropdownButtonRef = useRef(null);
+ const [deleteCategoriesConfirmModalVisible, setDeleteCategoriesConfirmModalVisible] = useState(false);
function fetchCategories() {
Policy.openPolicyCategoriesPage(route.params.policyID);
@@ -155,21 +157,24 @@ function WorkspaceCategoriesPage({policy, policyCategories, route}: WorkspaceCat
const selectedCategoriesArray = Object.keys(selectedCategories).filter((key) => selectedCategories[key]);
+ const handleDeleteCategories = () => {
+ setSelectedCategories({});
+ deleteWorkspaceCategories(route.params.policyID, selectedCategoriesArray);
+ setDeleteCategoriesConfirmModalVisible(false);
+ };
+
const getHeaderButtons = () => {
const options: Array>> = [];
if (selectedCategoriesArray.length > 0) {
options.push({
icon: Expensicons.Trashcan,
- text: translate('workspace.categories.deleteCategories'),
+ text: translate(selectedCategoriesArray.length === 1 ? 'workspace.categories.deleteCategory' : 'workspace.categories.deleteCategories'),
value: CONST.POLICY.CATEGORIES_BULK_ACTION_TYPES.DELETE,
- onSelected: () => {
- setSelectedCategories({});
- deleteWorkspaceCategories(route.params.policyID, selectedCategoriesArray);
- },
+ onSelected: () => setDeleteCategoriesConfirmModalVisible(true),
});
- const enabledCategories = selectedCategoriesArray.filter((categoryName) => policyCategories?.[categoryName].enabled);
+ const enabledCategories = selectedCategoriesArray.filter((categoryName) => policyCategories?.[categoryName]?.enabled);
if (enabledCategories.length > 0) {
const categoriesToDisable = selectedCategoriesArray
.filter((categoryName) => policyCategories?.[categoryName].enabled)
@@ -183,7 +188,7 @@ function WorkspaceCategoriesPage({policy, policyCategories, route}: WorkspaceCat
options.push({
icon: Expensicons.DocumentSlash,
- text: translate('workspace.categories.disableCategories'),
+ text: translate(enabledCategories.length === 1 ? 'workspace.categories.disableCategory' : 'workspace.categories.disableCategories'),
value: CONST.POLICY.CATEGORIES_BULK_ACTION_TYPES.DISABLE,
onSelected: () => {
setSelectedCategories({});
@@ -205,7 +210,7 @@ function WorkspaceCategoriesPage({policy, policyCategories, route}: WorkspaceCat
}, {});
options.push({
icon: Expensicons.Document,
- text: translate('workspace.categories.enableCategories'),
+ text: translate(disabledCategories.length === 1 ? 'workspace.categories.enableCategory' : 'workspace.categories.enableCategories'),
value: CONST.POLICY.CATEGORIES_BULK_ACTION_TYPES.ENABLE,
onSelected: () => {
setSelectedCategories({});
@@ -253,6 +258,8 @@ function WorkspaceCategoriesPage({policy, policyCategories, route}: WorkspaceCat
const isLoading = !isOffline && policyCategories === undefined;
+ const shouldShowEmptyState = !categoryList.some((category) => category.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE) && !isLoading;
+
return (
@@ -269,6 +276,16 @@ function WorkspaceCategoriesPage({policy, policyCategories, route}: WorkspaceCat
>
{!isSmallScreenWidth && getHeaderButtons()}
+ setDeleteCategoriesConfirmModalVisible(false)}
+ title={translate(selectedCategoriesArray.length === 1 ? 'workspace.categories.deleteCategory' : 'workspace.categories.deleteCategories')}
+ prompt={translate(selectedCategoriesArray.length === 1 ? 'workspace.categories.deleteCategoryPrompt' : 'workspace.categories.deleteCategoriesPrompt')}
+ confirmText={translate('common.delete')}
+ cancelText={translate('common.cancel')}
+ danger
+ />
{isSmallScreenWidth && {getHeaderButtons()}}
{translate('workspace.categories.subtitle')}
@@ -280,14 +297,14 @@ function WorkspaceCategoriesPage({policy, policyCategories, route}: WorkspaceCat
color={theme.spinner}
/>
)}
- {categoryList.length === 0 && !isLoading && (
+ {shouldShowEmptyState && (
)}
- {categoryList.length > 0 && (
+ {!shouldShowEmptyState && (
diff --git a/src/pages/workspace/taxes/WorkspaceCreateTaxPage.tsx b/src/pages/workspace/taxes/WorkspaceCreateTaxPage.tsx
new file mode 100644
index 000000000000..c0790bb8abd8
--- /dev/null
+++ b/src/pages/workspace/taxes/WorkspaceCreateTaxPage.tsx
@@ -0,0 +1,124 @@
+import type {StackScreenProps} from '@react-navigation/stack';
+import React, {useCallback} from 'react';
+import {View} from 'react-native';
+import AmountPicker from '@components/AmountPicker';
+import FormProvider from '@components/Form/FormProvider';
+import InputWrapper from '@components/Form/InputWrapper';
+import type {FormInputErrors, FormOnyxValues} from '@components/Form/types';
+import HeaderWithBackButton from '@components/HeaderWithBackButton';
+import ScreenWrapper from '@components/ScreenWrapper';
+import Text from '@components/Text';
+import TextPicker from '@components/TextPicker';
+import useLocalize from '@hooks/useLocalize';
+import useThemeStyles from '@hooks/useThemeStyles';
+import {createPolicyTax, getNextTaxCode, getTaxValueWithPercentage} from '@libs/actions/TaxRate';
+import Navigation from '@libs/Navigation/Navigation';
+import type {SettingsNavigatorParamList} from '@libs/Navigation/types';
+import * as ValidationUtils from '@libs/ValidationUtils';
+import AdminPolicyAccessOrNotFoundWrapper from '@pages/workspace/AdminPolicyAccessOrNotFoundWrapper';
+import PaidPolicyAccessOrNotFoundWrapper from '@pages/workspace/PaidPolicyAccessOrNotFoundWrapper';
+import type {WithPolicyAndFullscreenLoadingProps} from '@pages/workspace/withPolicyAndFullscreenLoading';
+import withPolicyAndFullscreenLoading from '@pages/workspace/withPolicyAndFullscreenLoading';
+import CONST from '@src/CONST';
+import ONYXKEYS from '@src/ONYXKEYS';
+import type SCREENS from '@src/SCREENS';
+import INPUT_IDS from '@src/types/form/WorkspaceNewTaxForm';
+import type {TaxRate} from '@src/types/onyx';
+
+type WorkspaceCreateTaxPageProps = WithPolicyAndFullscreenLoadingProps & StackScreenProps;
+
+function WorkspaceCreateTaxPage({
+ policy,
+ route: {
+ params: {policyID},
+ },
+}: WorkspaceCreateTaxPageProps) {
+ const styles = useThemeStyles();
+ const {translate} = useLocalize();
+
+ const validate = useCallback(
+ (values: FormOnyxValues): FormInputErrors => {
+ const errors = ValidationUtils.getFieldRequiredErrors(values, [INPUT_IDS.VALUE, INPUT_IDS.NAME]);
+
+ const value = values[INPUT_IDS.VALUE];
+ if (!ValidationUtils.isValidPercentage(value)) {
+ errors[INPUT_IDS.VALUE] = 'workspace.taxes.errors.valuePercentageRange';
+ }
+
+ const name = values[INPUT_IDS.NAME];
+ if (policy?.taxRates?.taxes && ValidationUtils.isExistingTaxName(name, policy.taxRates.taxes)) {
+ errors[INPUT_IDS.NAME] = 'workspace.taxes.errors.taxRateAlreadyExists';
+ }
+
+ return errors;
+ },
+ [policy?.taxRates?.taxes],
+ );
+
+ const submitForm = useCallback(
+ ({value, ...values}: FormOnyxValues) => {
+ const taxRate = {
+ ...values,
+ value: getTaxValueWithPercentage(value),
+ code: getNextTaxCode(values[INPUT_IDS.NAME], policy?.taxRates?.taxes),
+ } satisfies TaxRate;
+ createPolicyTax(policyID, taxRate);
+ Navigation.goBack();
+ },
+ [policy?.taxRates?.taxes, policyID],
+ );
+
+ return (
+
+
+
+
+
+
+
+
+ (v ? getTaxValueWithPercentage(v) : '')}
+ description={translate('workspace.taxes.value')}
+ rightLabel={translate('common.required')}
+ hideCurrencySymbol
+ extraSymbol={%}
+ />
+
+
+
+
+
+
+ );
+}
+
+WorkspaceCreateTaxPage.displayName = 'WorkspaceCreateTaxPage';
+
+export default withPolicyAndFullscreenLoading(WorkspaceCreateTaxPage);
diff --git a/src/pages/workspace/taxes/WorkspaceTaxesPage.tsx b/src/pages/workspace/taxes/WorkspaceTaxesPage.tsx
index 18123d109645..d0724f4592ba 100644
--- a/src/pages/workspace/taxes/WorkspaceTaxesPage.tsx
+++ b/src/pages/workspace/taxes/WorkspaceTaxesPage.tsx
@@ -17,12 +17,15 @@ import useTheme from '@hooks/useTheme';
import useThemeStyles from '@hooks/useThemeStyles';
import useWindowDimensions from '@hooks/useWindowDimensions';
import {openPolicyTaxesPage} from '@libs/actions/Policy';
+import {clearTaxRateError} from '@libs/actions/TaxRate';
+import Navigation from '@libs/Navigation/Navigation';
import type {WorkspacesCentralPaneNavigatorParamList} from '@navigation/types';
import AdminPolicyAccessOrNotFoundWrapper from '@pages/workspace/AdminPolicyAccessOrNotFoundWrapper';
import PaidPolicyAccessOrNotFoundWrapper from '@pages/workspace/PaidPolicyAccessOrNotFoundWrapper';
import withPolicyAndFullscreenLoading from '@pages/workspace/withPolicyAndFullscreenLoading';
import type {WithPolicyAndFullscreenLoadingProps} from '@pages/workspace/withPolicyAndFullscreenLoading';
import CONST from '@src/CONST';
+import ROUTES from '@src/ROUTES';
import type SCREENS from '@src/SCREENS';
type WorkspaceTaxesPageProps = WithPolicyAndFullscreenLoadingProps & StackScreenProps;
@@ -72,6 +75,8 @@ function WorkspaceTaxesPage({policy, route}: WorkspaceTaxesPageProps) {
keyForList: key,
isSelected: !!selectedTaxesIDs.includes(key),
isDisabledCheckbox: key === defaultExternalID,
+ pendingAction: value.pendingAction,
+ errors: value.errors,
rightElement: (
@@ -129,14 +134,14 @@ function WorkspaceTaxesPage({policy, route}: WorkspaceTaxesPageProps) {
{}}
+ onPress={() => Navigation.navigate(ROUTES.WORKSPACE_TAX_CREATE.getRoute(route.params.policyID))}
icon={Expensicons.Plus}
text={translate('workspace.taxes.addRate')}
style={[styles.mr3, isSmallScreenWidth && styles.w50]}
/>
{}}
+ onPress={() => Navigation.navigate(ROUTES.WORKSPACE_TAXES_SETTINGS.getRoute(route.params.policyID))}
icon={Expensicons.Gear}
text={translate('common.settings')}
style={[isSmallScreenWidth && styles.w50]}
@@ -183,6 +188,7 @@ function WorkspaceTaxesPage({policy, route}: WorkspaceTaxesPageProps) {
ListItem={TableListItem}
customListHeader={getCustomListHeader()}
listHeaderWrapperStyle={[styles.ph9, styles.pv3, styles.pb5]}
+ onDismissError={(item) => (item.keyForList ? clearTaxRateError(route.params.policyID, item.keyForList, item.pendingAction) : undefined)}
/>
diff --git a/src/pages/workspace/taxes/WorkspaceTaxesSettingsCustomTaxName.tsx b/src/pages/workspace/taxes/WorkspaceTaxesSettingsCustomTaxName.tsx
new file mode 100644
index 000000000000..892434ce2d52
--- /dev/null
+++ b/src/pages/workspace/taxes/WorkspaceTaxesSettingsCustomTaxName.tsx
@@ -0,0 +1,84 @@
+import type {StackScreenProps} from '@react-navigation/stack';
+import React from 'react';
+import {View} from 'react-native';
+import FormProvider from '@components/Form/FormProvider';
+import InputWrapper from '@components/Form/InputWrapper';
+import HeaderWithBackButton from '@components/HeaderWithBackButton';
+import ScreenWrapper from '@components/ScreenWrapper';
+import TextInput from '@components/TextInput';
+import useAutoFocusInput from '@hooks/useAutoFocusInput';
+import useLocalize from '@hooks/useLocalize';
+import useThemeStyles from '@hooks/useThemeStyles';
+import {setPolicyCustomTaxName} from '@libs/actions/Policy';
+import Navigation from '@libs/Navigation/Navigation';
+import type {SettingsNavigatorParamList} from '@libs/Navigation/types';
+import AdminPolicyAccessOrNotFoundWrapper from '@pages/workspace/AdminPolicyAccessOrNotFoundWrapper';
+import PaidPolicyAccessOrNotFoundWrapper from '@pages/workspace/PaidPolicyAccessOrNotFoundWrapper';
+import type {WithPolicyAndFullscreenLoadingProps} from '@pages/workspace/withPolicyAndFullscreenLoading';
+import withPolicyAndFullscreenLoading from '@pages/workspace/withPolicyAndFullscreenLoading';
+import CONST from '@src/CONST';
+import ONYXKEYS from '@src/ONYXKEYS';
+import ROUTES from '@src/ROUTES';
+import type SCREENS from '@src/SCREENS';
+import INPUT_IDS from '@src/types/form/WorkspaceTaxCustomName';
+import type {WorkspaceTaxCustomName} from '@src/types/form/WorkspaceTaxCustomName';
+
+type WorkspaceTaxesSettingsCustomTaxNameProps = WithPolicyAndFullscreenLoadingProps & StackScreenProps;
+
+function WorkspaceTaxesSettingsCustomTaxName({
+ route: {
+ params: {policyID},
+ },
+ policy,
+}: WorkspaceTaxesSettingsCustomTaxNameProps) {
+ const styles = useThemeStyles();
+ const {translate} = useLocalize();
+ const {inputCallbackRef} = useAutoFocusInput();
+
+ const submit = ({name}: WorkspaceTaxCustomName) => {
+ setPolicyCustomTaxName(policyID, name);
+ Navigation.goBack(ROUTES.WORKSPACE_TAXES_SETTINGS.getRoute(policyID));
+ };
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+WorkspaceTaxesSettingsCustomTaxName.displayName = 'WorkspaceTaxesSettingsCustomTaxName';
+
+export default withPolicyAndFullscreenLoading(WorkspaceTaxesSettingsCustomTaxName);
diff --git a/src/pages/workspace/taxes/WorkspaceTaxesSettingsForeignCurrency.tsx b/src/pages/workspace/taxes/WorkspaceTaxesSettingsForeignCurrency.tsx
new file mode 100644
index 000000000000..4a6626a78286
--- /dev/null
+++ b/src/pages/workspace/taxes/WorkspaceTaxesSettingsForeignCurrency.tsx
@@ -0,0 +1,68 @@
+import type {StackScreenProps} from '@react-navigation/stack';
+import React from 'react';
+import {View} from 'react-native';
+import HeaderWithBackButton from '@components/HeaderWithBackButton';
+import ScreenWrapper from '@components/ScreenWrapper';
+import type {ListItem} from '@components/SelectionList/types';
+import TaxPicker from '@components/TaxPicker';
+import useLocalize from '@hooks/useLocalize';
+import useThemeStyles from '@hooks/useThemeStyles';
+import {setForeignCurrencyDefault} from '@libs/actions/Policy';
+import Navigation from '@libs/Navigation/Navigation';
+import type {SettingsNavigatorParamList} from '@libs/Navigation/types';
+import AdminPolicyAccessOrNotFoundWrapper from '@pages/workspace/AdminPolicyAccessOrNotFoundWrapper';
+import PaidPolicyAccessOrNotFoundWrapper from '@pages/workspace/PaidPolicyAccessOrNotFoundWrapper';
+import type {WithPolicyAndFullscreenLoadingProps} from '@pages/workspace/withPolicyAndFullscreenLoading';
+import withPolicyAndFullscreenLoading from '@pages/workspace/withPolicyAndFullscreenLoading';
+import ROUTES from '@src/ROUTES';
+import type SCREENS from '@src/SCREENS';
+
+type WorkspaceTaxesSettingsForeignCurrencyProps = WithPolicyAndFullscreenLoadingProps &
+ StackScreenProps;
+
+function WorkspaceTaxesSettingsForeignCurrency({
+ route: {
+ params: {policyID},
+ },
+ policy,
+}: WorkspaceTaxesSettingsForeignCurrencyProps) {
+ const {translate} = useLocalize();
+ const styles = useThemeStyles();
+
+ const submit = ({keyForList}: ListItem) => {
+ setForeignCurrencyDefault(policyID, keyForList ?? '');
+ Navigation.goBack(ROUTES.WORKSPACE_TAXES_SETTINGS.getRoute(policyID));
+ };
+
+ return (
+
+
+
+ {({insets}) => (
+ <>
+
+
+
+
+
+ >
+ )}
+
+
+
+ );
+}
+
+WorkspaceTaxesSettingsForeignCurrency.displayName = 'WorkspaceTaxesSettingsForeignCurrency';
+
+export default withPolicyAndFullscreenLoading(WorkspaceTaxesSettingsForeignCurrency);
diff --git a/src/pages/workspace/taxes/WorkspaceTaxesSettingsPage.tsx b/src/pages/workspace/taxes/WorkspaceTaxesSettingsPage.tsx
new file mode 100644
index 000000000000..1fe6abb96b4c
--- /dev/null
+++ b/src/pages/workspace/taxes/WorkspaceTaxesSettingsPage.tsx
@@ -0,0 +1,90 @@
+import type {StackScreenProps} from '@react-navigation/stack';
+import React, {useMemo} from 'react';
+import {View} from 'react-native';
+import HeaderWithBackButton from '@components/HeaderWithBackButton';
+import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription';
+import OfflineWithFeedback from '@components/OfflineWithFeedback';
+import ScreenWrapper from '@components/ScreenWrapper';
+import ScrollView from '@components/ScrollView';
+import useLocalize from '@hooks/useLocalize';
+import useThemeStyles from '@hooks/useThemeStyles';
+import Navigation from '@libs/Navigation/Navigation';
+import type {SettingsNavigatorParamList} from '@libs/Navigation/types';
+import AdminPolicyAccessOrNotFoundWrapper from '@pages/workspace/AdminPolicyAccessOrNotFoundWrapper';
+import PaidPolicyAccessOrNotFoundWrapper from '@pages/workspace/PaidPolicyAccessOrNotFoundWrapper';
+import withPolicyAndFullscreenLoading from '@pages/workspace/withPolicyAndFullscreenLoading';
+import type {WithPolicyAndFullscreenLoadingProps} from '@pages/workspace/withPolicyAndFullscreenLoading';
+import ROUTES from '@src/ROUTES';
+import type SCREENS from '@src/SCREENS';
+
+type WorkspaceTaxesSettingsPageProps = WithPolicyAndFullscreenLoadingProps & StackScreenProps;
+
+function WorkspaceTaxesSettingsPage({
+ route: {
+ params: {policyID},
+ },
+ policy,
+}: WorkspaceTaxesSettingsPageProps) {
+ const styles = useThemeStyles();
+ const {translate} = useLocalize();
+
+ const menuItems = useMemo(
+ () => [
+ {
+ title: policy?.taxRates?.name,
+ description: translate('workspace.taxes.customTaxName'),
+ action: () => Navigation.navigate(ROUTES.WORKSPACE_TAXES_SETTINGS_CUSTOM_TAX_NAME.getRoute(policyID)),
+ pendingAction: policy?.taxRates?.pendingFields?.name,
+ },
+ {
+ title: policy?.taxRates?.taxes[policy?.taxRates?.defaultExternalID]?.name,
+ description: translate('workspace.taxes.workspaceDefault'),
+ action: () => Navigation.navigate(ROUTES.WORKSPACE_TAXES_SETTINGS_WORKSPACE_CURRENCY_DEFAULT.getRoute(policyID)),
+ pendingAction: policy?.taxRates?.pendingFields?.defaultExternalID,
+ },
+ {
+ title: policy?.taxRates?.taxes[policy?.taxRates?.foreignTaxDefault]?.name,
+ description: translate('workspace.taxes.foreignDefault'),
+ action: () => Navigation.navigate(ROUTES.WORKSPACE_TAXES_SETTINGS_FOREIGN_CURRENCY_DEFAULT.getRoute(policyID)),
+ pendingAction: policy?.taxRates?.pendingFields?.foreignTaxDefault,
+ },
+ ],
+ [policy?.taxRates, policyID, translate],
+ );
+
+ return (
+
+
+
+
+
+
+ {menuItems.map((item) => (
+
+
+
+ ))}
+
+
+
+
+
+ );
+}
+
+WorkspaceTaxesSettingsPage.displayName = 'WorkspaceTaxesSettingsPage';
+
+export default withPolicyAndFullscreenLoading(WorkspaceTaxesSettingsPage);
diff --git a/src/pages/workspace/taxes/WorkspaceTaxesSettingsWorkspaceCurrency.tsx b/src/pages/workspace/taxes/WorkspaceTaxesSettingsWorkspaceCurrency.tsx
new file mode 100644
index 000000000000..68c50f3af830
--- /dev/null
+++ b/src/pages/workspace/taxes/WorkspaceTaxesSettingsWorkspaceCurrency.tsx
@@ -0,0 +1,68 @@
+import type {StackScreenProps} from '@react-navigation/stack';
+import React from 'react';
+import {View} from 'react-native';
+import HeaderWithBackButton from '@components/HeaderWithBackButton';
+import ScreenWrapper from '@components/ScreenWrapper';
+import type {ListItem} from '@components/SelectionList/types';
+import TaxPicker from '@components/TaxPicker';
+import useLocalize from '@hooks/useLocalize';
+import useThemeStyles from '@hooks/useThemeStyles';
+import {setWorkspaceCurrencyDefault} from '@libs/actions/Policy';
+import Navigation from '@libs/Navigation/Navigation';
+import type {SettingsNavigatorParamList} from '@libs/Navigation/types';
+import AdminPolicyAccessOrNotFoundWrapper from '@pages/workspace/AdminPolicyAccessOrNotFoundWrapper';
+import PaidPolicyAccessOrNotFoundWrapper from '@pages/workspace/PaidPolicyAccessOrNotFoundWrapper';
+import type {WithPolicyAndFullscreenLoadingProps} from '@pages/workspace/withPolicyAndFullscreenLoading';
+import withPolicyAndFullscreenLoading from '@pages/workspace/withPolicyAndFullscreenLoading';
+import ROUTES from '@src/ROUTES';
+import type SCREENS from '@src/SCREENS';
+
+type WorkspaceTaxesSettingsWorkspaceCurrencyProps = WithPolicyAndFullscreenLoadingProps &
+ StackScreenProps;
+
+function WorkspaceTaxesSettingsWorkspaceCurrency({
+ route: {
+ params: {policyID},
+ },
+ policy,
+}: WorkspaceTaxesSettingsWorkspaceCurrencyProps) {
+ const {translate} = useLocalize();
+ const styles = useThemeStyles();
+
+ const submit = ({keyForList}: ListItem) => {
+ setWorkspaceCurrencyDefault(policyID, keyForList ?? '');
+ Navigation.goBack(ROUTES.WORKSPACE_TAXES_SETTINGS.getRoute(policyID));
+ };
+
+ return (
+
+
+
+ {({insets}) => (
+ <>
+
+
+
+
+
+ >
+ )}
+
+
+
+ );
+}
+
+WorkspaceTaxesSettingsWorkspaceCurrency.displayName = 'WorkspaceTaxesSettingsWorkspaceCurrency';
+
+export default withPolicyAndFullscreenLoading(WorkspaceTaxesSettingsWorkspaceCurrency);
diff --git a/src/styles/index.ts b/src/styles/index.ts
index df89cd823fa4..8a91291a0c71 100644
--- a/src/styles/index.ts
+++ b/src/styles/index.ts
@@ -3449,6 +3449,12 @@ const styles = (theme: ThemeColors) =>
zIndex: 1000,
},
+ invisibleImage: {
+ opacity: 0,
+ width: 200,
+ height: 200,
+ },
+
reportDropOverlay: {
backgroundColor: theme.dropUIBG,
zIndex: 2,
diff --git a/src/types/form/WorkspaceNewTaxForm.ts b/src/types/form/WorkspaceNewTaxForm.ts
new file mode 100644
index 000000000000..7ba9b8931bf4
--- /dev/null
+++ b/src/types/form/WorkspaceNewTaxForm.ts
@@ -0,0 +1,20 @@
+import type {ValueOf} from 'type-fest';
+import type Form from './Form';
+
+const INPUT_IDS = {
+ NAME: 'name',
+ VALUE: 'value',
+} as const;
+
+type InputID = ValueOf;
+
+type WorkspaceNewTaxForm = Form<
+ InputID,
+ {
+ [INPUT_IDS.NAME]: string;
+ [INPUT_IDS.VALUE]: string;
+ }
+>;
+
+export type {WorkspaceNewTaxForm};
+export default INPUT_IDS;
diff --git a/src/types/form/WorkspaceTaxCustomName.ts b/src/types/form/WorkspaceTaxCustomName.ts
new file mode 100644
index 000000000000..db6522732e7f
--- /dev/null
+++ b/src/types/form/WorkspaceTaxCustomName.ts
@@ -0,0 +1,18 @@
+import type {ValueOf} from 'type-fest';
+import type Form from './Form';
+
+const INPUT_IDS = {
+ NAME: 'name',
+} as const;
+
+type InputID = ValueOf;
+
+type WorkspaceTaxCustomName = Form<
+ InputID,
+ {
+ [INPUT_IDS.NAME]: string;
+ }
+>;
+
+export type {WorkspaceTaxCustomName};
+export default INPUT_IDS;
diff --git a/src/types/form/index.ts b/src/types/form/index.ts
index d88de0639663..ddd460e46eac 100644
--- a/src/types/form/index.ts
+++ b/src/types/form/index.ts
@@ -39,6 +39,8 @@ export type {WorkspaceSettingsForm} from './WorkspaceSettingsForm';
export type {ReportPhysicalCardForm} from './ReportPhysicalCardForm';
export type {WorkspaceDescriptionForm} from './WorkspaceDescriptionForm';
export type {PolicyTagNameForm} from './PolicyTagNameForm';
+export type {WorkspaceNewTaxForm} from './WorkspaceNewTaxForm';
export type {WorkspaceTagCreateForm} from './WorkspaceTagCreateForm';
+export type {WorkspaceTaxCustomName} from './WorkspaceTaxCustomName';
export type {PolicyCreateDistanceRateForm} from './PolicyCreateDistanceRateForm';
export type {default as Form} from './Form';
diff --git a/src/types/onyx/Policy.ts b/src/types/onyx/Policy.ts
index 688300933a5e..84ac101a7d7a 100644
--- a/src/types/onyx/Policy.ts
+++ b/src/types/onyx/Policy.ts
@@ -32,26 +32,29 @@ type DisabledFields = {
reimbursable?: boolean;
};
-type TaxRate = {
+type TaxRate = OnyxCommon.OnyxValueWithOfflineFeedback<{
/** Name of the a tax rate. */
name: string;
/** The value of the tax rate as percentage. */
value: string;
- /** The code associated with the tax rate. */
- code: string;
+ /** The code associated with the tax rate. If a tax is created in old dot, code field is undefined */
+ code?: string;
/** This contains the tax name and tax value as one name */
- modifiedName: string;
+ modifiedName?: string;
/** Indicates if the tax rate is disabled. */
isDisabled?: boolean;
-};
+
+ /** An error message to display to the user */
+ errors?: OnyxCommon.Errors;
+}>;
type TaxRates = Record;
-type TaxRatesWithDefault = {
+type TaxRatesWithDefault = OnyxCommon.OnyxValueWithOfflineFeedback<{
/** Name of the tax */
name: string;
@@ -66,7 +69,13 @@ type TaxRatesWithDefault = {
/** List of tax names and values */
taxes: TaxRates;
-};
+
+ /** An error message to display to the user */
+ errors?: OnyxCommon.Errors;
+
+ /** Error objects keyed by field name containing errors keyed by microtime */
+ errorFields?: OnyxCommon.ErrorFields;
+}>;
type ConnectionLastSync = {
successfulDate?: string;
diff --git a/src/types/onyx/PolicyCategory.ts b/src/types/onyx/PolicyCategory.ts
index 0fd498632dbc..b42bceec0468 100644
--- a/src/types/onyx/PolicyCategory.ts
+++ b/src/types/onyx/PolicyCategory.ts
@@ -15,7 +15,7 @@ type PolicyCategory = OnyxCommon.OnyxValueWithOfflineFeedback<{
/** "General Ledger code" that corresponds to this category in an accounting system. Similar to an ID. */
// eslint-disable-next-line @typescript-eslint/naming-convention
- 'GL Code': string;
+ 'GL Code'?: string;
/** An ID for this category from an external accounting system */
externalID: string;
diff --git a/src/types/onyx/PolicyTag.ts b/src/types/onyx/PolicyTag.ts
index 70a0884c30bd..8066b85d1e44 100644
--- a/src/types/onyx/PolicyTag.ts
+++ b/src/types/onyx/PolicyTag.ts
@@ -9,7 +9,7 @@ type PolicyTag = {
/** "General Ledger code" that corresponds to this tag in an accounting system. Similar to an ID. */
// eslint-disable-next-line @typescript-eslint/naming-convention
- 'GL Code': string;
+ 'GL Code'?: string;
/** A list of errors keyed by microtime */
errors?: OnyxCommon.Errors | null;
diff --git a/src/types/utils/callOrReturn.ts b/src/types/utils/callOrReturn.ts
index d51226544962..6accb08f5a2d 100644
--- a/src/types/utils/callOrReturn.ts
+++ b/src/types/utils/callOrReturn.ts
@@ -1,6 +1,8 @@
-function callOrReturn(value: TValue | (() => TValue)): TValue {
+type Func = (...args: T) => R;
+
+function callOrReturn(value: TReturn | Func, ...args: TArgs): TReturn {
if (typeof value === 'function') {
- return (value as () => TValue)();
+ return (value as Func)(...args);
}
return value;
diff --git a/tests/actions/PolicyTest.js b/tests/actions/PolicyTest.ts
similarity index 59%
rename from tests/actions/PolicyTest.js
rename to tests/actions/PolicyTest.ts
index 5a994aaf600e..e59fec068d65 100644
--- a/tests/actions/PolicyTest.js
+++ b/tests/actions/PolicyTest.ts
@@ -1,9 +1,10 @@
-import _ from 'lodash';
import Onyx from 'react-native-onyx';
+import type {OnyxCollection} from 'react-native-onyx';
import CONST from '@src/CONST';
-import OnyxUpdateManager from '../../src/libs/actions/OnyxUpdateManager';
-import * as Policy from '../../src/libs/actions/Policy';
-import ONYXKEYS from '../../src/ONYXKEYS';
+import OnyxUpdateManager from '@src/libs/actions/OnyxUpdateManager';
+import * as Policy from '@src/libs/actions/Policy';
+import ONYXKEYS from '@src/ONYXKEYS';
+import type {PolicyMembers, Policy as PolicyType, Report, ReportAction, ReportActions} from '@src/types/onyx';
import * as TestHelper from '../utils/TestHelper';
import waitForBatchedUpdates from '../utils/waitForBatchedUpdates';
@@ -20,12 +21,14 @@ describe('actions/Policy', () => {
});
beforeEach(() => {
+ // @ts-expect-error TODO: Remove this once TestHelper (https://github.com/Expensify/App/issues/25318) is migrated to TypeScript.
global.fetch = TestHelper.getGlobalFetchMock();
return Onyx.clear().then(waitForBatchedUpdates);
});
describe('createWorkspace', () => {
it('creates a new workspace', async () => {
+ // @ts-expect-error TODO: Remove this once TestHelper (https://github.com/Expensify/App/issues/25318) is migrated to TypeScript.
fetch.pause();
Onyx.set(ONYXKEYS.SESSION, {email: ESH_EMAIL, accountID: ESH_ACCOUNT_ID});
await waitForBatchedUpdates();
@@ -38,7 +41,7 @@ describe('actions/Policy', () => {
Policy.createWorkspace(ESH_EMAIL, true, WORKSPACE_NAME, policyID);
await waitForBatchedUpdates();
- let policy = await new Promise((resolve) => {
+ let policy: OnyxCollection = await new Promise((resolve) => {
const connectionID = Onyx.connect({
key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`,
waitForCollectionCallback: true,
@@ -50,15 +53,15 @@ describe('actions/Policy', () => {
});
// check if policy was created with correct values
- expect(policy.id).toBe(policyID);
- expect(policy.name).toBe(WORKSPACE_NAME);
- expect(policy.type).toBe(CONST.POLICY.TYPE.FREE);
- expect(policy.role).toBe(CONST.POLICY.ROLE.ADMIN);
- expect(policy.owner).toBe(ESH_EMAIL);
- expect(policy.isPolicyExpenseChatEnabled).toBe(true);
- expect(policy.pendingAction).toBe(CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD);
-
- const policyMembers = await new Promise((resolve) => {
+ expect(policy?.id).toBe(policyID);
+ expect(policy?.name).toBe(WORKSPACE_NAME);
+ expect(policy?.type).toBe(CONST.POLICY.TYPE.FREE);
+ expect(policy?.role).toBe(CONST.POLICY.ROLE.ADMIN);
+ expect(policy?.owner).toBe(ESH_EMAIL);
+ expect(policy?.isPolicyExpenseChatEnabled).toBe(true);
+ expect(policy?.pendingAction).toBe(CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD);
+
+ const policyMembers: OnyxCollection = await new Promise((resolve) => {
const connectionID = Onyx.connect({
key: `${ONYXKEYS.COLLECTION.POLICY_MEMBERS}${policyID}`,
waitForCollectionCallback: true,
@@ -70,9 +73,9 @@ describe('actions/Policy', () => {
});
// check if the user was added as an admin to the policy
- expect(policyMembers[ESH_ACCOUNT_ID].role).toBe(CONST.POLICY.ROLE.ADMIN);
+ expect(policyMembers?.[ESH_ACCOUNT_ID]?.role).toBe(CONST.POLICY.ROLE.ADMIN);
- let allReports = await new Promise((resolve) => {
+ let allReports: OnyxCollection = await new Promise((resolve) => {
const connectionID = Onyx.connect({
key: ONYXKEYS.COLLECTION.REPORT,
waitForCollectionCallback: true,
@@ -84,12 +87,12 @@ describe('actions/Policy', () => {
});
// Three reports should be created: #announce, #admins and expense report
- const workspaceReports = _.filter(allReports, (report) => report.policyID === policyID);
- expect(_.size(workspaceReports)).toBe(3);
- _.forEach(workspaceReports, (report) => {
- expect(report.pendingFields.addWorkspaceRoom).toBe(CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD);
- expect(report.participantAccountIDs).toEqual([ESH_ACCOUNT_ID]);
- switch (report.chatType) {
+ const workspaceReports = Object.values(allReports ?? {}).filter((report) => report?.policyID === policyID);
+ expect(workspaceReports.length).toBe(3);
+ workspaceReports.forEach((report) => {
+ expect(report?.pendingFields?.addWorkspaceRoom).toBe(CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD);
+ expect(report?.participantAccountIDs).toEqual([ESH_ACCOUNT_ID]);
+ switch (report?.chatType) {
case CONST.REPORT.CHAT_TYPE.POLICY_ADMINS: {
adminReportID = report.reportID;
break;
@@ -107,7 +110,7 @@ describe('actions/Policy', () => {
}
});
- let reportActions = await new Promise((resolve) => {
+ let reportActions: OnyxCollection = await new Promise((resolve) => {
const connectionID = Onyx.connect({
key: ONYXKEYS.COLLECTION.REPORT_ACTIONS,
waitForCollectionCallback: true,
@@ -119,20 +122,21 @@ describe('actions/Policy', () => {
});
// Each of the three reports should have a a `CREATED` action.
- let adminReportActions = _.values(reportActions[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${adminReportID}`]);
- let announceReportActions = _.values(reportActions[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${announceReportID}`]);
- let expenseReportActions = _.values(reportActions[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${expenseReportID}`]);
- let workspaceReportActions = _.concat(adminReportActions, announceReportActions, expenseReportActions);
- _.forEach([adminReportActions, announceReportActions, expenseReportActions], (actions) => {
- expect(_.size(actions)).toBe(1);
+ let adminReportActions: ReportAction[] = Object.values(reportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${adminReportID}`] ?? {});
+ let announceReportActions: ReportAction[] = Object.values(reportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${announceReportID}`] ?? {});
+ let expenseReportActions: ReportAction[] = Object.values(reportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${expenseReportID}`] ?? {});
+ let workspaceReportActions: ReportAction[] = adminReportActions.concat(announceReportActions, expenseReportActions);
+ [adminReportActions, announceReportActions, expenseReportActions].forEach((actions) => {
+ expect(actions.length).toBe(1);
});
- _.forEach([...adminReportActions, ...announceReportActions, ...expenseReportActions], (reportAction) => {
+ [...adminReportActions, ...announceReportActions, ...expenseReportActions].forEach((reportAction) => {
expect(reportAction.actionName).toBe(CONST.REPORT.ACTIONS.TYPE.CREATED);
expect(reportAction.pendingAction).toBe(CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD);
expect(reportAction.actorAccountID).toBe(ESH_ACCOUNT_ID);
});
// Check for success data
+ // @ts-expect-error TODO: Remove this once TestHelper (https://github.com/Expensify/App/issues/25318) is migrated to TypeScript.
fetch.resume();
await waitForBatchedUpdates();
@@ -148,7 +152,7 @@ describe('actions/Policy', () => {
});
// Check if the policy pending action was cleared
- expect(policy.pendingAction).toBeFalsy();
+ expect(policy?.pendingAction).toBeFalsy();
allReports = await new Promise((resolve) => {
const connectionID = Onyx.connect({
@@ -162,9 +166,9 @@ describe('actions/Policy', () => {
});
// Check if the report pending action and fields were cleared
- _.forEach(allReports, (report) => {
- expect(report.pendingAction).toBeFalsy();
- expect(report.pendingFields.addWorkspaceRoom).toBeFalsy();
+ Object.values(allReports ?? {}).forEach((report) => {
+ expect(report?.pendingAction).toBeFalsy();
+ expect(report?.pendingFields?.addWorkspaceRoom).toBeFalsy();
});
reportActions = await new Promise((resolve) => {
@@ -179,11 +183,11 @@ describe('actions/Policy', () => {
});
// Check if the report action pending action was cleared
- adminReportActions = _.values(reportActions[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${adminReportID}`]);
- announceReportActions = _.values(reportActions[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${announceReportID}`]);
- expenseReportActions = _.values(reportActions[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${expenseReportID}`]);
- workspaceReportActions = _.concat(adminReportActions, announceReportActions, expenseReportActions);
- _.forEach(workspaceReportActions, (reportAction) => {
+ adminReportActions = Object.values(reportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${adminReportID}`] ?? {});
+ announceReportActions = Object.values(reportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${announceReportID}`] ?? {});
+ expenseReportActions = Object.values(reportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${expenseReportID}`] ?? {});
+ workspaceReportActions = adminReportActions.concat(announceReportActions, expenseReportActions);
+ workspaceReportActions.forEach((reportAction) => {
expect(reportAction.pendingAction).toBeFalsy();
});
});
diff --git a/tests/perf-test/OptionsListUtils.perf-test.ts b/tests/perf-test/OptionsListUtils.perf-test.ts
new file mode 100644
index 000000000000..d81be3165919
--- /dev/null
+++ b/tests/perf-test/OptionsListUtils.perf-test.ts
@@ -0,0 +1,78 @@
+import {rand} from '@ngneat/falso';
+import type * as NativeNavigation from '@react-navigation/native';
+import {measureFunction} from 'reassure';
+import * as OptionsListUtils from '@libs/OptionsListUtils';
+import CONST from '@src/CONST';
+import ONYXKEYS from '@src/ONYXKEYS';
+import type {PersonalDetails} from '@src/types/onyx';
+import type Report from '@src/types/onyx/Report';
+import createCollection from '../utils/collections/createCollection';
+import createPersonalDetails from '../utils/collections/personalDetails';
+import {getRandomDate} from '../utils/collections/reportActions';
+import createRandomReport from '../utils/collections/reports';
+import waitForBatchedUpdates from '../utils/waitForBatchedUpdates';
+
+const REPORTS_COUNT = 5000;
+const PERSONAL_DETAILS_LIST_COUNT = 1000;
+const SEARCH_VALUE = 'TestingValue';
+
+const reports = createCollection(
+ (item) => `${ONYXKEYS.COLLECTION.REPORT}${item.reportID}`,
+ (index) => ({
+ ...createRandomReport(index),
+ type: rand(Object.values(CONST.REPORT.TYPE)),
+ lastVisibleActionCreated: getRandomDate(),
+ }),
+ REPORTS_COUNT,
+);
+
+const personalDetails = createCollection(
+ (item) => item.accountID,
+ (index) => createPersonalDetails(index),
+ PERSONAL_DETAILS_LIST_COUNT,
+);
+
+const mockedBetas = Object.values(CONST.BETAS);
+
+jest.mock('@react-navigation/native', () => {
+ const actualNav = jest.requireActual('@react-navigation/native');
+ return {
+ ...actualNav,
+ createNavigationContainerRef: () => ({
+ getState: () => jest.fn(),
+ }),
+ } as typeof NativeNavigation;
+});
+
+/* GetOption is the private function and is never called directly, we are testing the functions which call getOption with different params */
+describe('OptionsListUtils', () => {
+ /* Testing getSearchOptions */
+ test('[OptionsListUtils] getSearchOptions with search value', async () => {
+ await waitForBatchedUpdates();
+ await measureFunction(() => OptionsListUtils.getSearchOptions(reports, personalDetails, SEARCH_VALUE, mockedBetas));
+ });
+
+ /* Testing getShareLogOptions */
+ test('[OptionsListUtils] getShareLogOptions with search value', async () => {
+ await waitForBatchedUpdates();
+ await measureFunction(() => OptionsListUtils.getShareLogOptions(reports, personalDetails, SEARCH_VALUE, mockedBetas));
+ });
+
+ /* Testing getFilteredOptions */
+ test('[OptionsListUtils] getFilteredOptions with search value', async () => {
+ await waitForBatchedUpdates();
+ await measureFunction(() => OptionsListUtils.getFilteredOptions(reports, personalDetails, mockedBetas, SEARCH_VALUE));
+ });
+
+ /* Testing getShareDestinationOptions */
+ test('[OptionsListUtils] getShareDestinationOptions with search value', async () => {
+ await waitForBatchedUpdates();
+ await measureFunction(() => OptionsListUtils.getShareDestinationOptions(reports, personalDetails, mockedBetas, SEARCH_VALUE));
+ });
+
+ /* Testing getMemberInviteOptions */
+ test('[OptionsListUtils] getMemberInviteOptions with search value', async () => {
+ await waitForBatchedUpdates();
+ await measureFunction(() => OptionsListUtils.getMemberInviteOptions(personalDetails, mockedBetas, SEARCH_VALUE));
+ });
+});
diff --git a/tests/perf-test/OptionsSelector.perf-test.js b/tests/perf-test/OptionsSelector.perf-test.tsx
similarity index 67%
rename from tests/perf-test/OptionsSelector.perf-test.js
rename to tests/perf-test/OptionsSelector.perf-test.tsx
index 6104ded05c6a..835e2a15673c 100644
--- a/tests/perf-test/OptionsSelector.perf-test.js
+++ b/tests/perf-test/OptionsSelector.perf-test.tsx
@@ -1,12 +1,15 @@
import {fireEvent} from '@testing-library/react-native';
+import type {RenderResult} from '@testing-library/react-native';
import React from 'react';
+import type {ComponentType} from 'react';
import {measurePerformance} from 'reassure';
-import _ from 'underscore';
+import type {WithLocalizeProps} from '@components/withLocalize';
+import type {WithNavigationFocusProps} from '@components/withNavigationFocus';
import OptionsSelector from '@src/components/OptionsSelector';
import variables from '@src/styles/variables';
-jest.mock('../../src/components/withLocalize', () => (Component) => {
- function WrappedComponent(props) {
+jest.mock('@src/components/withLocalize', () => (Component: ComponentType) => {
+ function WrappedComponent(props: WithLocalizeProps) {
return (
(Component) => {
return WrappedComponent;
});
-jest.mock('../../src/components/withNavigationFocus', () => (Component) => {
- function WithNavigationFocus(props) {
+jest.mock('@src/components/withNavigationFocus', () => (Component: ComponentType) => {
+ function WithNavigationFocus(props: WithNavigationFocusProps) {
return (
(Component) => {
return WithNavigationFocus;
});
-const generateSections = (sectionConfigs) =>
- _.map(sectionConfigs, ({numItems, indexOffset, shouldShow = true}) => ({
- data: Array.from({length: numItems}, (_v, i) => ({
+type GenerateSectionsProps = Array<{numberOfItems: number; indexOffset: number; shouldShow?: boolean}>;
+
+const generateSections = (sections: GenerateSectionsProps) =>
+ sections.map(({numberOfItems, indexOffset, shouldShow = true}) => ({
+ data: Array.from({length: numberOfItems}, (v, i) => ({
text: `Item ${i + indexOffset}`,
keyForList: `item-${i + indexOffset}`,
})),
@@ -45,15 +50,15 @@ const generateSections = (sectionConfigs) =>
shouldShow,
}));
-const singleSectionSConfig = [{numItems: 1000, indexOffset: 0}];
+const singleSectionsConfig = [{numberOfItems: 1000, indexOffset: 0}];
const mutlipleSectionsConfig = [
- {numItems: 1000, indexOffset: 0},
- {numItems: 100, indexOffset: 70},
+ {numberOfItems: 1000, indexOffset: 0},
+ {numberOfItems: 100, indexOffset: 70},
];
-
+// @ts-expect-error TODO: Remove this once OptionsSelector is migrated to TypeScript.
function OptionsSelectorWrapper(args) {
- const sections = generateSections(singleSectionSConfig);
+ const sections = generateSections(singleSectionsConfig);
return (
{
- const scenario = (screen) => {
+ const scenario = ((screen: RenderResult) => {
const textInput = screen.getByTestId('options-selector-input');
fireEvent.changeText(textInput, 'test');
fireEvent.changeText(textInput, 'test2');
fireEvent.changeText(textInput, 'test3');
- };
+ }) as Awaited<(screen: RenderResult) => Promise>;
measurePerformance(, {scenario});
});
@@ -85,11 +90,11 @@ test('[OptionsSelector] should render multiple sections', () => {
});
test('[OptionsSelector] should press a list items', () => {
- const scenario = (screen) => {
+ const scenario = ((screen: RenderResult) => {
fireEvent.press(screen.getByText('Item 1'));
fireEvent.press(screen.getByText('Item 5'));
fireEvent.press(screen.getByText('Item 10'));
- };
+ }) as Awaited<(screen: RenderResult) => Promise>;
measurePerformance(, {scenario});
});
@@ -97,10 +102,10 @@ test('[OptionsSelector] should press a list items', () => {
test('[OptionsSelector] should scroll and press few items', () => {
const sections = generateSections(mutlipleSectionsConfig);
- const generateEventData = (numOptions, optionRowHeight) => ({
+ const generateEventData = (numberOfOptions: number, optionRowHeight: number) => ({
nativeEvent: {
contentOffset: {
- y: optionRowHeight * numOptions,
+ y: optionRowHeight * numberOfOptions,
},
contentSize: {
height: optionRowHeight * 10,
@@ -115,7 +120,7 @@ test('[OptionsSelector] should scroll and press few items', () => {
const eventData = generateEventData(100, variables.optionRowHeight);
const eventData2 = generateEventData(200, variables.optionRowHeight);
- const scenario = async (screen) => {
+ const scenario = async (screen: RenderResult) => {
fireEvent.press(screen.getByText('Item 10'));
fireEvent.scroll(screen.getByTestId('options-list'), eventData);
fireEvent.press(await screen.findByText('Item 100'));
diff --git a/tests/perf-test/PolicyUtils.perf-test.ts b/tests/perf-test/PolicyUtils.perf-test.ts
new file mode 100644
index 000000000000..98403b310f25
--- /dev/null
+++ b/tests/perf-test/PolicyUtils.perf-test.ts
@@ -0,0 +1,33 @@
+import {measureFunction} from 'reassure';
+import {getMemberAccountIDsForWorkspace} from '@libs/PolicyUtils';
+import type {PersonalDetails, PolicyMember} from '@src/types/onyx';
+import createCollection from '../utils/collections/createCollection';
+import createPersonalDetails from '../utils/collections/personalDetails';
+import createRandomPolicyMember from '../utils/collections/policyMembers';
+
+describe('PolicyUtils', () => {
+ describe('getMemberAccountIDsForWorkspace', () => {
+ test('500 policy members with personal details', async () => {
+ const policyMembers = createCollection(
+ (_, index) => index,
+ () => createRandomPolicyMember(),
+ );
+ const personalDetails = createCollection((_, index) => index, createPersonalDetails);
+
+ await measureFunction(() => getMemberAccountIDsForWorkspace(policyMembers, personalDetails));
+ });
+
+ test('500 policy members with errors and personal details', async () => {
+ const policyMembers = createCollection(
+ (_, index) => index,
+ () => ({
+ ...createRandomPolicyMember(),
+ errors: {error: 'Error message'},
+ }),
+ );
+ const personalDetails = createCollection((_, index) => index, createPersonalDetails);
+
+ await measureFunction(() => getMemberAccountIDsForWorkspace(policyMembers, personalDetails));
+ });
+ });
+});
diff --git a/tests/perf-test/SearchPage.perf-test.js b/tests/perf-test/SearchPage.perf-test.tsx
similarity index 50%
rename from tests/perf-test/SearchPage.perf-test.js
rename to tests/perf-test/SearchPage.perf-test.tsx
index be6b6a5d78f9..3f3395092b26 100644
--- a/tests/perf-test/SearchPage.perf-test.js
+++ b/tests/perf-test/SearchPage.perf-test.tsx
@@ -1,14 +1,22 @@
+import type * as NativeNavigation from '@react-navigation/native';
+import type {StackScreenProps} from '@react-navigation/stack';
import {fireEvent, screen, waitFor} from '@testing-library/react-native';
+import type {TextMatch} from '@testing-library/react-native/build/matches';
import React from 'react';
+import type {ComponentType} from 'react';
import Onyx from 'react-native-onyx';
+import type {OnyxCollection, OnyxEntry} from 'react-native-onyx';
import {measurePerformance} from 'reassure';
-import _ from 'underscore';
import {LocaleContextProvider} from '@components/LocaleContextProvider';
+import type {WithNavigationFocusProps} from '@components/withNavigationFocus';
+import type {RootStackParamList} from '@libs/Navigation/types';
import SearchPage from '@pages/SearchPage';
-import ComposeProviders from '../../src/components/ComposeProviders';
-import OnyxProvider from '../../src/components/OnyxProvider';
-import CONST from '../../src/CONST';
-import ONYXKEYS from '../../src/ONYXKEYS';
+import ComposeProviders from '@src/components/ComposeProviders';
+import OnyxProvider from '@src/components/OnyxProvider';
+import CONST from '@src/CONST';
+import ONYXKEYS from '@src/ONYXKEYS';
+import type SCREENS from '@src/SCREENS';
+import type {Beta, PersonalDetails, Report} from '@src/types/onyx';
import createCollection from '../utils/collections/createCollection';
import createPersonalDetails from '../utils/collections/personalDetails';
import createRandomReport from '../utils/collections/reports';
@@ -18,22 +26,22 @@ import waitForBatchedUpdates from '../utils/waitForBatchedUpdates';
import wrapOnyxWithWaitForBatchedUpdates from '../utils/wrapOnyxWithWaitForBatchedUpdates';
jest.mock('lodash/debounce', () =>
- jest.fn((fn) => {
+ jest.fn((fn: Record>) => {
// eslint-disable-next-line no-param-reassign
fn.cancel = jest.fn();
return fn;
}),
);
-jest.mock('../../src/libs/Log');
+jest.mock('@src/libs/Log');
-jest.mock('../../src/libs/API', () => ({
+jest.mock('@src/libs/API', () => ({
write: jest.fn(),
makeRequestWithSideEffects: jest.fn(),
read: jest.fn(),
}));
-jest.mock('../../src/libs/Navigation/Navigation');
+jest.mock('@src/libs/Navigation/Navigation');
const mockedNavigate = jest.fn();
jest.mock('@react-navigation/native', () => {
@@ -50,11 +58,11 @@ jest.mock('@react-navigation/native', () => {
addListener: () => jest.fn(),
}),
createNavigationContainerRef: jest.fn(),
- };
+ } as typeof NativeNavigation;
});
-jest.mock('../../src/components/withNavigationFocus', () => (Component) => {
- function WithNavigationFocus(props) {
+jest.mock('@src/components/withNavigationFocus', () => (Component: ComponentType) => {
+ function WithNavigationFocus(props: WithNavigationFocusProps) {
return (
(Component) => {
});
const getMockedReports = (length = 100) =>
- createCollection(
+ createCollection(
(item) => `${ONYXKEYS.COLLECTION.REPORT}${item.reportID}`,
(index) => createRandomReport(index),
length,
);
const getMockedPersonalDetails = (length = 100) =>
- createCollection(
+ createCollection(
(item) => item.accountID,
(index) => createPersonalDetails(index),
length,
);
const mockedReports = getMockedReports(600);
-const mockedBetas = _.values(CONST.BETAS);
+const mockedBetas = Object.values(CONST.BETAS);
const mockedPersonalDetails = getMockedPersonalDetails(100);
beforeAll(() =>
Onyx.init({
keys: ONYXKEYS,
safeEvictionKeys: [ONYXKEYS.COLLECTION.REPORT],
- registerStorageEventListener: () => {},
}),
);
// Initialize the network key for OfflineWithFeedback
beforeEach(() => {
+ // @ts-expect-error TODO: Remove this once TestHelper (https://github.com/Expensify/App/issues/25318) is migrated to TypeScript.
global.fetch = TestHelper.getGlobalFetchMock();
wrapOnyxWithWaitForBatchedUpdates(Onyx);
Onyx.merge(ONYXKEYS.NETWORK, {isOffline: false});
@@ -108,7 +116,13 @@ afterEach(() => {
PusherHelper.teardown();
});
-function SearchPageWrapper(args) {
+type SearchPageProps = StackScreenProps & {
+ betas: OnyxEntry;
+ reports: OnyxCollection;
+ isSearchingForReports: OnyxEntry;
+};
+
+function SearchPageWrapper(args: SearchPageProps) {
return (
{
+ // @ts-expect-error TODO: Remove this once TestHelper (https://github.com/Expensify/App/issues/25318) is migrated to TypeScript.
const {addListener} = TestHelper.createAddListenerMock();
const scenario = async () => {
@@ -134,81 +149,93 @@ test('[Search Page] should interact when text input changes', async () => {
const navigation = {addListener};
- return waitForBatchedUpdates()
- .then(() =>
- Onyx.multiSet({
- ...mockedReports,
- [ONYXKEYS.PERSONAL_DETAILS_LIST]: mockedPersonalDetails,
- [ONYXKEYS.BETAS]: mockedBetas,
- [ONYXKEYS.IS_SEARCHING_FOR_REPORTS]: true,
- }),
- )
- .then(() => measurePerformance(, {scenario}));
+ return (
+ waitForBatchedUpdates()
+ .then(() =>
+ Onyx.multiSet({
+ ...mockedReports,
+ [ONYXKEYS.PERSONAL_DETAILS_LIST]: mockedPersonalDetails,
+ [ONYXKEYS.BETAS]: mockedBetas,
+ [ONYXKEYS.IS_SEARCHING_FOR_REPORTS]: true,
+ }),
+ )
+ // @ts-expect-error TODO: Remove this once TestHelper (https://github.com/Expensify/App/issues/25318) is migrated to TypeScript.
+ .then(() => measurePerformance(, {scenario}))
+ );
});
test('[Search Page] should render selection list', async () => {
+ // @ts-expect-error TODO: Remove this once TestHelper (https://github.com/Expensify/App/issues/25318) is migrated to TypeScript.
const {triggerTransitionEnd, addListener} = TestHelper.createAddListenerMock();
const smallMockedPersonalDetails = getMockedPersonalDetails(5);
const scenario = async () => {
await screen.findByTestId('SearchPage');
- await waitFor(triggerTransitionEnd);
+ await waitFor(triggerTransitionEnd as Awaited<() => Promise>);
await screen.findByTestId('selection-list');
- await screen.findByText(smallMockedPersonalDetails['1'].login);
- await screen.findByText(smallMockedPersonalDetails['2'].login);
+ await screen.findByText(smallMockedPersonalDetails['1'].login as TextMatch);
+ await screen.findByText(smallMockedPersonalDetails['2'].login as TextMatch);
};
const navigation = {addListener};
- return waitForBatchedUpdates()
- .then(() =>
- Onyx.multiSet({
- ...mockedReports,
- [ONYXKEYS.PERSONAL_DETAILS_LIST]: smallMockedPersonalDetails,
- [ONYXKEYS.BETAS]: mockedBetas,
- [ONYXKEYS.IS_SEARCHING_FOR_REPORTS]: true,
- }),
- )
- .then(() => measurePerformance(, {scenario}));
+ return (
+ waitForBatchedUpdates()
+ .then(() =>
+ Onyx.multiSet({
+ ...mockedReports,
+ [ONYXKEYS.PERSONAL_DETAILS_LIST]: smallMockedPersonalDetails,
+ [ONYXKEYS.BETAS]: mockedBetas,
+ [ONYXKEYS.IS_SEARCHING_FOR_REPORTS]: true,
+ }),
+ )
+ // @ts-expect-error TODO: Remove this once TestHelper (https://github.com/Expensify/App/issues/25318) is migrated to TypeScript.
+ .then(() => measurePerformance(, {scenario}))
+ );
});
test('[Search Page] should search in selection list', async () => {
+ // @ts-expect-error TODO: Remove this once TestHelper (https://github.com/Expensify/App/issues/25318) is migrated to TypeScript.
const {triggerTransitionEnd, addListener} = TestHelper.createAddListenerMock();
const scenario = async () => {
await screen.findByTestId('SearchPage');
- await waitFor(triggerTransitionEnd);
+ await waitFor(triggerTransitionEnd as Awaited<() => Promise>);
const input = screen.getByTestId('selection-list-text-input');
const searchValue = mockedPersonalDetails['88'].login;
fireEvent.changeText(input, searchValue);
- await screen.findByText(searchValue);
+ await screen.findByText(searchValue as TextMatch);
};
const navigation = {addListener};
- return waitForBatchedUpdates()
- .then(() =>
- Onyx.multiSet({
- ...mockedReports,
- [ONYXKEYS.PERSONAL_DETAILS_LIST]: mockedPersonalDetails,
- [ONYXKEYS.BETAS]: mockedBetas,
- [ONYXKEYS.IS_SEARCHING_FOR_REPORTS]: true,
- }),
- )
- .then(() => measurePerformance(, {scenario}));
+ return (
+ waitForBatchedUpdates()
+ .then(() =>
+ Onyx.multiSet({
+ ...mockedReports,
+ [ONYXKEYS.PERSONAL_DETAILS_LIST]: mockedPersonalDetails,
+ [ONYXKEYS.BETAS]: mockedBetas,
+ [ONYXKEYS.IS_SEARCHING_FOR_REPORTS]: true,
+ }),
+ )
+ // @ts-expect-error TODO: Remove this once TestHelper (https://github.com/Expensify/App/issues/25318) is migrated to TypeScript.
+ .then(() => measurePerformance(, {scenario}))
+ );
});
test('[Search Page] should click on list item', async () => {
+ // @ts-expect-error TODO: Remove this once TestHelper (https://github.com/Expensify/App/issues/25318) is migrated to TypeScript.
const {triggerTransitionEnd, addListener} = TestHelper.createAddListenerMock();
const scenario = async () => {
await screen.findByTestId('SearchPage');
const input = screen.getByTestId('selection-list-text-input');
- await waitFor(triggerTransitionEnd);
+ await waitFor(triggerTransitionEnd as Awaited<() => Promise>);
- const searchValue = mockedPersonalDetails['4'].login;
+ const searchValue = mockedPersonalDetails['4'].login as TextMatch;
fireEvent.changeText(input, searchValue);
const optionButton = await screen.findByText(searchValue);
@@ -216,14 +243,17 @@ test('[Search Page] should click on list item', async () => {
};
const navigation = {addListener};
- return waitForBatchedUpdates()
- .then(() =>
- Onyx.multiSet({
- ...mockedReports,
- [ONYXKEYS.PERSONAL_DETAILS_LIST]: mockedPersonalDetails,
- [ONYXKEYS.BETAS]: mockedBetas,
- [ONYXKEYS.IS_SEARCHING_FOR_REPORTS]: true,
- }),
- )
- .then(() => measurePerformance(, {scenario}));
+ return (
+ waitForBatchedUpdates()
+ .then(() =>
+ Onyx.multiSet({
+ ...mockedReports,
+ [ONYXKEYS.PERSONAL_DETAILS_LIST]: mockedPersonalDetails,
+ [ONYXKEYS.BETAS]: mockedBetas,
+ [ONYXKEYS.IS_SEARCHING_FOR_REPORTS]: true,
+ }),
+ )
+ // @ts-expect-error TODO: Remove this once TestHelper (https://github.com/Expensify/App/issues/25318) is migrated to TypeScript.
+ .then(() => measurePerformance(, {scenario}))
+ );
});
diff --git a/tests/unit/PhoneNumberTest.js b/tests/unit/PhoneNumberTest.ts
similarity index 100%
rename from tests/unit/PhoneNumberTest.js
rename to tests/unit/PhoneNumberTest.ts
diff --git a/tests/unit/UserUtilsTest.js b/tests/unit/UserUtilsTest.ts
similarity index 86%
rename from tests/unit/UserUtilsTest.js
rename to tests/unit/UserUtilsTest.ts
index f0f20fc6d4cb..f91f2a499e79 100644
--- a/tests/unit/UserUtilsTest.js
+++ b/tests/unit/UserUtilsTest.ts
@@ -1,4 +1,4 @@
-import * as UserUtils from '../../src/libs/UserUtils';
+import * as UserUtils from '@src/libs/UserUtils';
describe('UserUtils', () => {
it('should return the default avatar from the avatar url', () => {
diff --git a/tests/unit/ViolationUtilsTest.js b/tests/unit/ViolationUtilsTest.ts
similarity index 93%
rename from tests/unit/ViolationUtilsTest.js
rename to tests/unit/ViolationUtilsTest.ts
index 15a3a4f7de07..354a90802077 100644
--- a/tests/unit/ViolationUtilsTest.js
+++ b/tests/unit/ViolationUtilsTest.ts
@@ -2,6 +2,7 @@ import {beforeEach} from '@jest/globals';
import Onyx from 'react-native-onyx';
import ViolationsUtils from '@libs/Violations/ViolationsUtils';
import ONYXKEYS from '@src/ONYXKEYS';
+import type {PolicyCategories, PolicyTagList, Transaction, TransactionViolation} from '@src/types/onyx';
const categoryOutOfPolicyViolation = {
name: 'categoryOutOfPolicy',
@@ -24,15 +25,15 @@ const missingTagViolation = {
};
describe('getViolationsOnyxData', () => {
- let transaction;
- let transactionViolations;
- let policyRequiresTags;
- let policyTags;
- let policyRequiresCategories;
- let policyCategories;
+ let transaction: Transaction;
+ let transactionViolations: TransactionViolation[];
+ let policyRequiresTags: boolean;
+ let policyTags: PolicyTagList;
+ let policyRequiresCategories: boolean;
+ let policyCategories: PolicyCategories;
beforeEach(() => {
- transaction = {transactionID: '123'};
+ transaction = {transactionID: '123', reportID: '1234', amount: 100, comment: {}, created: '2023-07-24 13:46:20', merchant: 'United Airlines', currency: 'USD'};
transactionViolations = [];
policyRequiresTags = false;
policyTags = {};
@@ -62,12 +63,12 @@ describe('getViolationsOnyxData', () => {
describe('policyRequiresCategories', () => {
beforeEach(() => {
policyRequiresCategories = true;
- policyCategories = {Food: {enabled: true}};
+ policyCategories = {Food: {name: 'Food', unencodedName: '', enabled: true, areCommentsRequired: false, externalID: '1234', origin: '12345'}};
transaction.category = 'Food';
});
it('should add missingCategory violation if no category is included', () => {
- transaction.category = null;
+ transaction.category = undefined;
const result = ViolationsUtils.getViolationsOnyxData(transaction, transactionViolations, policyRequiresTags, policyTags, policyRequiresCategories, policyCategories);
expect(result.value).toEqual(expect.arrayContaining([missingCategoryViolation, ...transactionViolations]));
});
@@ -132,6 +133,7 @@ describe('getViolationsOnyxData', () => {
Lunch: {name: 'Lunch', enabled: true},
Dinner: {name: 'Dinner', enabled: true},
},
+ orderWeight: 1,
},
};
transaction.tag = 'Lunch';
@@ -209,6 +211,7 @@ describe('getViolationsOnyxData', () => {
},
},
required: true,
+ orderWeight: 1,
},
Region: {
name: 'Region',
@@ -218,6 +221,8 @@ describe('getViolationsOnyxData', () => {
enabled: true,
},
},
+ required: true,
+ orderWeight: 2,
},
Project: {
name: 'Project',
@@ -228,6 +233,7 @@ describe('getViolationsOnyxData', () => {
},
},
required: true,
+ orderWeight: 3,
},
};
});
diff --git a/tests/utils/collections/policyMembers.ts b/tests/utils/collections/policyMembers.ts
new file mode 100644
index 000000000000..076c8ddb2d3d
--- /dev/null
+++ b/tests/utils/collections/policyMembers.ts
@@ -0,0 +1,9 @@
+import {randWord} from '@ngneat/falso';
+import type {PolicyMember} from '@src/types/onyx';
+
+export default function createRandomPolicyMember(): PolicyMember {
+ return {
+ role: randWord(),
+ errors: {},
+ };
+}