diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml
index 624c00de6831..5030ea1c2f2b 100644
--- a/.github/workflows/deploy.yml
+++ b/.github/workflows/deploy.yml
@@ -28,6 +28,24 @@ jobs:
- name: 🚀 Push tags to trigger staging deploy 🚀
run: git push --tags
+
+ - name: Warn deployers if staging deploy failed
+ if: ${{ failure() }}
+ uses: 8398a7/action-slack@v3
+ with:
+ status: custom
+ custom_payload: |
+ {
+ channel: '#deployer',
+ attachments: [{
+ color: "#DB4545",
+ pretext: ``,
+ text: `💥 NewDot staging deploy failed. 💥`,
+ }]
+ }
+ env:
+ GITHUB_TOKEN: ${{ github.token }}
+ SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK }}
deployProduction:
runs-on: ubuntu-latest
@@ -65,6 +83,24 @@ jobs:
PR_LIST: ${{ steps.getReleasePRList.outputs.PR_LIST }}
- name: 🚀 Create release to trigger production deploy 🚀
- run: gh release create ${{ env.PRODUCTION_VERSION }} --generate-notes
+ run: gh release create ${{ env.PRODUCTION_VERSION }} --notes '${{ steps.getReleaseBody.outputs.RELEASE_BODY }}'
env:
GITHUB_TOKEN: ${{ steps.setupGitForOSBotify.outputs.OS_BOTIFY_API_TOKEN }}
+
+ - name: Warn deployers if production deploy failed
+ if: ${{ failure() }}
+ uses: 8398a7/action-slack@v3
+ with:
+ status: custom
+ custom_payload: |
+ {
+ channel: '#deployer',
+ attachments: [{
+ color: "#DB4545",
+ pretext: ``,
+ text: `💥 NewDot production deploy failed. 💥`,
+ }]
+ }
+ env:
+ GITHUB_TOKEN: ${{ github.token }}
+ SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK }}
diff --git a/android/app/build.gradle b/android/app/build.gradle
index 410fd3163051..d4cc7471a72b 100644
--- a/android/app/build.gradle
+++ b/android/app/build.gradle
@@ -107,8 +107,8 @@ android {
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
multiDexEnabled rootProject.ext.multiDexEnabled
- versionCode 1009000510
- versionName "9.0.5-10"
+ versionCode 1009000600
+ versionName "9.0.6-0"
// Supported language variants must be declared here to avoid from being removed during the compilation.
// This also helps us to not include unnecessary language variants in the APK.
resConfigs "en", "es"
diff --git a/assets/images/simple-illustrations/simple-illustration__empty-state.svg b/assets/images/simple-illustrations/simple-illustration__empty-state.svg
new file mode 100644
index 000000000000..154b2269c285
--- /dev/null
+++ b/assets/images/simple-illustrations/simple-illustration__empty-state.svg
@@ -0,0 +1,102 @@
+
+
+
diff --git a/ios/NewExpensify/Info.plist b/ios/NewExpensify/Info.plist
index 25a2d9c67427..2826f7e51db9 100644
--- a/ios/NewExpensify/Info.plist
+++ b/ios/NewExpensify/Info.plist
@@ -19,7 +19,7 @@
CFBundlePackageType
APPL
CFBundleShortVersionString
- 9.0.5
+ 9.0.6
CFBundleSignature
????
CFBundleURLTypes
@@ -40,7 +40,7 @@
CFBundleVersion
- 9.0.5.10
+ 9.0.6.0
FullStory
OrgId
diff --git a/ios/NewExpensifyTests/Info.plist b/ios/NewExpensifyTests/Info.plist
index d4ecead8fcd1..cf3e420de4e7 100644
--- a/ios/NewExpensifyTests/Info.plist
+++ b/ios/NewExpensifyTests/Info.plist
@@ -15,10 +15,10 @@
CFBundlePackageType
BNDL
CFBundleShortVersionString
- 9.0.5
+ 9.0.6
CFBundleSignature
????
CFBundleVersion
- 9.0.5.10
+ 9.0.6.0
diff --git a/ios/NotificationServiceExtension/Info.plist b/ios/NotificationServiceExtension/Info.plist
index 86c4dfbca3fd..58cdb65c40e9 100644
--- a/ios/NotificationServiceExtension/Info.plist
+++ b/ios/NotificationServiceExtension/Info.plist
@@ -11,9 +11,9 @@
CFBundleName
$(PRODUCT_NAME)
CFBundleShortVersionString
- 9.0.5
+ 9.0.6
CFBundleVersion
- 9.0.5.10
+ 9.0.6.0
NSExtension
NSExtensionPointIdentifier
diff --git a/package-lock.json b/package-lock.json
index 4baf29980990..64f5f7a6a94d 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "new.expensify",
- "version": "9.0.5-10",
+ "version": "9.0.6-0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "new.expensify",
- "version": "9.0.5-10",
+ "version": "9.0.6-0",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
@@ -79,7 +79,7 @@
"react-content-loader": "^7.0.0",
"react-dom": "18.1.0",
"react-error-boundary": "^4.0.11",
- "react-fast-pdf": "1.0.13",
+ "react-fast-pdf": "1.0.14",
"react-map-gl": "^7.1.3",
"react-native": "0.73.4",
"react-native-android-location-enabler": "^2.0.1",
@@ -36987,9 +36987,9 @@
}
},
"node_modules/react-fast-pdf": {
- "version": "1.0.13",
- "resolved": "https://registry.npmjs.org/react-fast-pdf/-/react-fast-pdf-1.0.13.tgz",
- "integrity": "sha512-rF7NQZ26rJAI8ysRJaG71dl2c7AIq48ibbn7xCyF3lEZ/yOjA8BeR0utRwDjaHGtswQscgETboilhaaH5UtIYg==",
+ "version": "1.0.14",
+ "resolved": "https://registry.npmjs.org/react-fast-pdf/-/react-fast-pdf-1.0.14.tgz",
+ "integrity": "sha512-iWomykxvnZtokIKpRK5xpaRfXz9ufrY7AVANtIBYsAZtX5/7VDlpIQwieljfMZwFc96TyceCnneufsgXpykTQw==",
"dependencies": {
"react-pdf": "^7.7.0",
"react-window": "^1.8.10"
diff --git a/package.json b/package.json
index 8bab9c187e80..9f1c3ffca7a7 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "new.expensify",
- "version": "9.0.5-10",
+ "version": "9.0.6-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.",
@@ -133,7 +133,7 @@
"react-content-loader": "^7.0.0",
"react-dom": "18.1.0",
"react-error-boundary": "^4.0.11",
- "react-fast-pdf": "1.0.13",
+ "react-fast-pdf": "1.0.14",
"react-map-gl": "^7.1.3",
"react-native": "0.73.4",
"react-native-android-location-enabler": "^2.0.1",
diff --git a/patches/@expensify+react-native-live-markdown+0.1.88.patch b/patches/@expensify+react-native-live-markdown+0.1.88.patch
deleted file mode 100644
index f745786a088e..000000000000
--- a/patches/@expensify+react-native-live-markdown+0.1.88.patch
+++ /dev/null
@@ -1,13 +0,0 @@
-diff --git a/node_modules/@expensify/react-native-live-markdown/lib/module/web/cursorUtils.js b/node_modules/@expensify/react-native-live-markdown/lib/module/web/cursorUtils.js
-index e975fb2..6a4b510 100644
---- a/node_modules/@expensify/react-native-live-markdown/lib/module/web/cursorUtils.js
-+++ b/node_modules/@expensify/react-native-live-markdown/lib/module/web/cursorUtils.js
-@@ -53,7 +53,7 @@ function setCursorPosition(target, start, end = null) {
- // 3. Caret at the end of whole input, when pressing enter
- // 4. All other placements
- if (prevChar === '\n' && prevTextLength !== undefined && prevTextLength < textCharacters.length) {
-- if (nextChar !== '\n') {
-+ if (nextChar !== '\n' && i !== n - 1 && nextChar) {
- range.setStart(textNodes[i + 1], 0);
- } else if (i !== textNodes.length - 1) {
- range.setStart(textNodes[i], 1);
diff --git a/patches/@expensify+react-native-live-markdown+0.1.91.patch b/patches/@expensify+react-native-live-markdown+0.1.91.patch
new file mode 100644
index 000000000000..c77e46accae3
--- /dev/null
+++ b/patches/@expensify+react-native-live-markdown+0.1.91.patch
@@ -0,0 +1,13 @@
+diff --git a/node_modules/@expensify/react-native-live-markdown/src/web/cursorUtils.ts b/node_modules/@expensify/react-native-live-markdown/src/web/cursorUtils.ts
+index 1cda659..ba5c3c3 100644
+--- a/node_modules/@expensify/react-native-live-markdown/src/web/cursorUtils.ts
++++ b/node_modules/@expensify/react-native-live-markdown/src/web/cursorUtils.ts
+@@ -66,7 +66,7 @@ function setCursorPosition(target: HTMLElement, start: number, end: number | nul
+ // 3. Caret at the end of whole input, when pressing enter
+ // 4. All other placements
+ if (prevChar === '\n' && prevTextLength !== undefined && prevTextLength < textCharacters.length) {
+- if (nextChar !== '\n') {
++ if (nextChar && nextChar !== '\n' && i !== n - 1) {
+ range.setStart(textNodes[i + 1] as Node, 0);
+ } else if (i !== textNodes.length - 1) {
+ range.setStart(textNodes[i] as Node, 1);
diff --git a/scripts/applyPatches.sh b/scripts/applyPatches.sh
index a4be88984561..9145629015ee 100755
--- a/scripts/applyPatches.sh
+++ b/scripts/applyPatches.sh
@@ -8,24 +8,17 @@ SCRIPTS_DIR=$(dirname "${BASH_SOURCE[0]}")
source "$SCRIPTS_DIR/shellUtils.sh"
# Wrapper to run patch-package.
-# We use `script` to preserve colorization when the output of patch-package is piped to tee
-# and we provide /dev/null to discard the output rather than sending it to a file
-# `script` has different syntax on macOS vs linux, so that's why we need a wrapper function
function patchPackage {
OS="$(uname)"
- if [[ "$OS" == "Darwin" ]]; then
- # macOS
- script -q /dev/null npx patch-package --error-on-fail
- elif [[ "$OS" == "Linux" ]]; then
- # Ubuntu/Linux
- script -q -c "npx patch-package --error-on-fail" /dev/null
+ if [[ "$OS" == "Darwin" || "$OS" == "Linux" ]]; then
+ npx patch-package --error-on-fail
else
error "Unsupported OS: $OS"
+ exit 1
fi
}
# Run patch-package and capture its output and exit code, while still displaying the original output to the terminal
-# (we use `script -q /dev/null` to preserve colorization in the output)
TEMP_OUTPUT="$(mktemp)"
patchPackage 2>&1 | tee "$TEMP_OUTPUT"
EXIT_CODE=${PIPESTATUS[0]}
@@ -36,7 +29,7 @@ rm -f "$TEMP_OUTPUT"
echo "$OUTPUT" | grep -q "Warning:"
WARNING_FOUND=$?
-printf "\n";
+printf "\n"
# Determine the final exit code
if [ "$EXIT_CODE" -eq 0 ]; then
diff --git a/src/App.tsx b/src/App.tsx
index 21025d34a661..98b5d4afeb1d 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -20,6 +20,7 @@ import OnyxProvider from './components/OnyxProvider';
import PopoverContextProvider from './components/PopoverProvider';
import SafeArea from './components/SafeArea';
import ScrollOffsetContextProvider from './components/ScrollOffsetContextProvider';
+import {SearchContextProvider} from './components/Search/SearchContext';
import ThemeIllustrationsProvider from './components/ThemeIllustrationsProvider';
import ThemeProvider from './components/ThemeProvider';
import ThemeStylesProvider from './components/ThemeStylesProvider';
@@ -91,6 +92,7 @@ function App({url}: AppProps) {
VolumeContextProvider,
VideoPopoverMenuContextProvider,
KeyboardProvider,
+ SearchContextProvider,
]}
>
diff --git a/src/CONST.ts b/src/CONST.ts
index 2ffc48bee694..50df9118a74e 100755
--- a/src/CONST.ts
+++ b/src/CONST.ts
@@ -840,6 +840,8 @@ const CONST = {
IOU: 'iou',
TASK: 'task',
INVOICE: 'invoice',
+ PAYCHECK: 'paycheck',
+ BILL: 'bill',
},
CHAT_TYPE: chatTypes,
WORKSPACE_CHAT_ROOMS: {
@@ -1123,8 +1125,6 @@ const CONST = {
// around each header.
EMOJI_NUM_PER_ROW: 8,
- EMOJI_FREQUENT_ROW_COUNT: 3,
-
EMOJI_DEFAULT_SKIN_TONE: -1,
// Amount of emojis to render ahead at the end of the update cycle
@@ -2452,6 +2452,7 @@ const CONST = {
PRIVATE_NOTES: 'privateNotes',
DELETE: 'delete',
MARK_AS_INCOMPLETE: 'markAsIncomplete',
+ CANCEL_PAYMENT: 'cancelPayment',
UNAPPROVE: 'unapprove',
},
EDIT_REQUEST_FIELD: {
@@ -3897,6 +3898,7 @@ const CONST = {
},
EVENTS: {
SCROLLING: 'scrolling',
+ ON_RETURN_TO_OLD_DOT: 'onReturnToOldDot',
},
CHAT_HEADER_LOADER_HEIGHT: 36,
@@ -5163,6 +5165,9 @@ const CONST = {
DONE: 'done',
PAID: 'paid',
VIEW: 'view',
+ REVIEW: 'review',
+ HOLD: 'hold',
+ UNHOLD: 'unhold',
},
TRANSACTION_TYPE: {
CASH: 'cash',
@@ -5245,6 +5250,13 @@ const CONST = {
},
EXCLUDE_FROM_LAST_VISITED_PATH: [SCREENS.NOT_FOUND, SCREENS.SAML_SIGN_IN, SCREENS.VALIDATE_LOGIN] as string[],
+
+ EMPTY_STATE_MEDIA: {
+ ANIMATION: 'animation',
+ ILLUSTRATION: 'illustration',
+ VIDEO: 'video',
+ },
+
UPGRADE_FEATURE_INTRO_MAPPING: [
{
id: 'reportFields',
@@ -5255,11 +5267,16 @@ const CONST = {
icon: 'Pencil',
},
],
+
REPORT_FIELD_TYPES: {
TEXT: 'text',
DATE: 'date',
LIST: 'dropdown',
},
+
+ NAVIGATION_ACTIONS: {
+ RESET: 'RESET',
+ },
} as const;
type Country = keyof typeof CONST.ALL_COUNTRIES;
diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts
index 8cb684b983cb..8abb7738289c 100755
--- a/src/ONYXKEYS.ts
+++ b/src/ONYXKEYS.ts
@@ -320,6 +320,9 @@ const ONYXKEYS = {
/** Onboarding Purpose selected by the user during Onboarding flow */
ONBOARDING_PURPOSE_SELECTED: 'onboardingPurposeSelected',
+ /** Onboarding error message to be displayed to the user */
+ ONBOARDING_ERROR_MESSAGE: 'onboardingErrorMessage',
+
/** Onboarding policyID selected by the user during Onboarding flow */
ONBOARDING_POLICY_ID: 'onboardingPolicyID',
@@ -442,6 +445,9 @@ const ONYXKEYS = {
* So for example: card_12345_Expensify Card
*/
WORKSPACE_CARDS_LIST: 'card_',
+
+ /** The bank account that Expensify Card payments will be reconciled against */
+ SHARED_NVP_EXPENSIFY_CARD_CONTINUOUS_RECONCILIATION_CONNECTION: 'sharedNVP_expensifyCard_continuousReconciliationConnection_',
},
/** List of Form ids */
@@ -532,8 +538,8 @@ const ONYXKEYS = {
REPORT_VIRTUAL_CARD_FRAUD_DRAFT: 'reportVirtualCardFraudFormDraft',
GET_PHYSICAL_CARD_FORM: 'getPhysicalCardForm',
GET_PHYSICAL_CARD_FORM_DRAFT: 'getPhysicalCardFormDraft',
- REPORT_FIELD_EDIT_FORM: 'reportFieldEditForm',
- REPORT_FIELD_EDIT_FORM_DRAFT: 'reportFieldEditFormDraft',
+ REPORT_FIELDS_EDIT_FORM: 'reportFieldsEditForm',
+ REPORT_FIELDS_EDIT_FORM_DRAFT: 'reportFieldsEditFormDraft',
REIMBURSEMENT_ACCOUNT_FORM: 'reimbursementAccount',
REIMBURSEMENT_ACCOUNT_FORM_DRAFT: 'reimbursementAccountDraft',
PERSONAL_BANK_ACCOUNT_FORM: 'personalBankAccount',
@@ -619,7 +625,7 @@ type OnyxFormValuesMapping = {
[ONYXKEYS.FORMS.REPORT_VIRTUAL_CARD_FRAUD]: FormTypes.ReportVirtualCardFraudForm;
[ONYXKEYS.FORMS.REPORT_PHYSICAL_CARD_FORM]: FormTypes.ReportPhysicalCardForm;
[ONYXKEYS.FORMS.GET_PHYSICAL_CARD_FORM]: FormTypes.GetPhysicalCardForm;
- [ONYXKEYS.FORMS.REPORT_FIELD_EDIT_FORM]: FormTypes.ReportFieldEditForm;
+ [ONYXKEYS.FORMS.REPORT_FIELDS_EDIT_FORM]: FormTypes.ReportFieldsEditForm;
[ONYXKEYS.FORMS.REIMBURSEMENT_ACCOUNT_FORM]: FormTypes.ReimbursementAccountForm;
[ONYXKEYS.FORMS.PERSONAL_BANK_ACCOUNT_FORM]: FormTypes.PersonalBankAccountForm;
[ONYXKEYS.FORMS.WORKSPACE_DESCRIPTION_FORM]: FormTypes.WorkspaceDescriptionForm;
@@ -689,6 +695,7 @@ type OnyxCollectionValuesMapping = {
[ONYXKEYS.COLLECTION.SHARED_NVP_PRIVATE_USER_BILLING_GRACE_PERIOD_END]: OnyxTypes.BillingGraceEndPeriod;
[ONYXKEYS.COLLECTION.SHARED_NVP_PRIVATE_EXPENSIFY_CARD_SETTINGS]: OnyxTypes.ExpensifyCardSettings;
[ONYXKEYS.COLLECTION.WORKSPACE_CARDS_LIST]: OnyxTypes.WorkspaceCardsList;
+ [ONYXKEYS.COLLECTION.SHARED_NVP_EXPENSIFY_CARD_CONTINUOUS_RECONCILIATION_CONNECTION]: OnyxTypes.BankAccount;
};
type OnyxValuesMapping = {
@@ -794,6 +801,7 @@ type OnyxValuesMapping = {
[ONYXKEYS.MAX_CANVAS_HEIGHT]: number;
[ONYXKEYS.MAX_CANVAS_WIDTH]: number;
[ONYXKEYS.ONBOARDING_PURPOSE_SELECTED]: string;
+ [ONYXKEYS.ONBOARDING_ERROR_MESSAGE]: string;
[ONYXKEYS.ONBOARDING_POLICY_ID]: string;
[ONYXKEYS.ONBOARDING_ADMINS_CHAT_REPORT_ID]: string;
[ONYXKEYS.IS_SEARCHING_FOR_REPORTS]: boolean;
diff --git a/src/ROUTES.ts b/src/ROUTES.ts
index 054f38b9ec92..2189522e45ea 100644
--- a/src/ROUTES.ts
+++ b/src/ROUTES.ts
@@ -54,6 +54,11 @@ const ROUTES = {
getRoute: (query: string, reportID: string) => `search/${query}/view/${reportID}` as const,
},
+ TRANSACTION_HOLD_REASON_RHP: {
+ route: '/search/:query/hold/:transactionID',
+ getRoute: (query: string, transactionID: string) => `search/${query}/hold/${transactionID}` as const,
+ },
+
// This is a utility route used to go to the user's concierge chat, or the sign-in page if the user's not authenticated
CONCIERGE: 'concierge',
FLAG_COMMENT: {
@@ -671,6 +676,10 @@ const ROUTES = {
route: 'settings/workspaces/:policyID/accounting/quickbooks-online/invoice-account-selector',
getRoute: (policyID: string) => `settings/workspaces/${policyID}/accounting/quickbooks-online/invoice-account-selector` as const,
},
+ WORKSPACE_ACCOUNTING_RECONCILIATION_ACCOUNT_SETTINGS: {
+ route: 'settings/workspaces/:policyID/accounting/:connection/card-reconciliation',
+ getRoute: (policyID: string, connection: ValueOf) => `settings/workspaces/${policyID}/accounting/${connection}/card-reconciliation` as const,
+ },
WORKSPACE_CATEGORIES: {
route: 'settings/workspaces/:policyID/categories',
getRoute: (policyID: string) => `settings/workspaces/${policyID}/categories` as const,
@@ -792,30 +801,30 @@ const ROUTES = {
route: 'settings/workspaces/:policyID/reportFields/new',
getRoute: (policyID: string) => `settings/workspaces/${policyID}/reportFields/new` as const,
},
- WORKSPACE_REPORT_FIELD_SETTINGS: {
- route: 'settings/workspaces/:policyID/reportField/:reportFieldID/edit',
- getRoute: (policyID: string, reportFieldID: string) => `settings/workspaces/${policyID}/reportField/${encodeURIComponent(reportFieldID)}/edit` as const,
+ WORKSPACE_REPORT_FIELDS_SETTINGS: {
+ route: 'settings/workspaces/:policyID/reportFields/:reportFieldID/edit',
+ getRoute: (policyID: string, reportFieldID: string) => `settings/workspaces/${policyID}/reportFields/${encodeURIComponent(reportFieldID)}/edit` as const,
},
- WORKSPACE_REPORT_FIELD_LIST_VALUES: {
- route: 'settings/workspaces/:policyID/reportField/listValues/:reportFieldID?',
- getRoute: (policyID: string, reportFieldID?: string) => `settings/workspaces/${policyID}/reportField/listValues/${encodeURIComponent(reportFieldID ?? '')}` as const,
+ WORKSPACE_REPORT_FIELDS_LIST_VALUES: {
+ route: 'settings/workspaces/:policyID/reportFields/listValues/:reportFieldID?',
+ getRoute: (policyID: string, reportFieldID?: string) => `settings/workspaces/${policyID}/reportFields/listValues/${encodeURIComponent(reportFieldID ?? '')}` as const,
},
- WORKSPACE_REPORT_FIELD_ADD_VALUE: {
- route: 'settings/workspaces/:policyID/reportField/addValue/:reportFieldID?',
- getRoute: (policyID: string, reportFieldID?: string) => `settings/workspaces/${policyID}/reportField/addValue/${encodeURIComponent(reportFieldID ?? '')}` as const,
+ WORKSPACE_REPORT_FIELDS_ADD_VALUE: {
+ route: 'settings/workspaces/:policyID/reportFields/addValue/:reportFieldID?',
+ getRoute: (policyID: string, reportFieldID?: string) => `settings/workspaces/${policyID}/reportFields/addValue/${encodeURIComponent(reportFieldID ?? '')}` as const,
},
- WORKSPACE_REPORT_FIELD_VALUE_SETTINGS: {
- route: 'settings/workspaces/:policyID/reportField/:valueIndex/:reportFieldID?',
+ WORKSPACE_REPORT_FIELDS_VALUE_SETTINGS: {
+ route: 'settings/workspaces/:policyID/reportFields/:valueIndex/:reportFieldID?',
getRoute: (policyID: string, valueIndex: number, reportFieldID?: string) =>
- `settings/workspaces/${policyID}/reportField/${valueIndex}/${encodeURIComponent(reportFieldID ?? '')}` as const,
+ `settings/workspaces/${policyID}/reportFields/${valueIndex}/${encodeURIComponent(reportFieldID ?? '')}` as const,
},
- WORKSPACE_REPORT_FIELD_EDIT_VALUE: {
- route: 'settings/workspaces/:policyID/reportField/new/:valueIndex/edit',
- getRoute: (policyID: string, valueIndex: number) => `settings/workspaces/${policyID}/reportField/new/${valueIndex}/edit` as const,
+ WORKSPACE_REPORT_FIELDS_EDIT_VALUE: {
+ route: 'settings/workspaces/:policyID/reportFields/new/:valueIndex/edit',
+ getRoute: (policyID: string, valueIndex: number) => `settings/workspaces/${policyID}/reportFields/new/${valueIndex}/edit` as const,
},
- WORKSPACE_EDIT_REPORT_FIELD_INITIAL_VALUE: {
- route: 'settings/workspaces/:policyID/reportField/:reportFieldID/edit/initialValue',
- getRoute: (policyID: string, reportFieldID: string) => `settings/workspaces/${policyID}/reportField/${encodeURIComponent(reportFieldID)}/edit/initialValue` as const,
+ WORKSPACE_EDIT_REPORT_FIELDS_INITIAL_VALUE: {
+ route: 'settings/workspaces/:policyID/reportFields/:reportFieldID/edit/initialValue',
+ getRoute: (policyID: string, reportFieldID: string) => `settings/workspaces/${policyID}/reportFields/${encodeURIComponent(reportFieldID)}/edit/initialValue` as const,
},
WORKSPACE_EXPENSIFY_CARD: {
route: 'settings/workspaces/:policyID/expensify-card',
diff --git a/src/SCREENS.ts b/src/SCREENS.ts
index c6b7da12e572..0768ca8bb291 100644
--- a/src/SCREENS.ts
+++ b/src/SCREENS.ts
@@ -30,6 +30,7 @@ const SCREENS = {
SEARCH: {
CENTRAL_PANE: 'Search_Central_Pane',
REPORT_RHP: 'Search_Report_RHP',
+ TRANSACTION_HOLD_REASON_RHP: 'Search_Transaction_Hold_Reason_RHP',
BOTTOM_TAB: 'Search_Bottom_Tab',
},
SETTINGS: {
@@ -329,6 +330,7 @@ const SCREENS = {
SAGE_INTACCT_NON_REIMBURSABLE_CREDIT_CARD_ACCOUNT: 'Policy_Accounting_Sage_Intacct_Non_Reimbursable_Credit_Card_Account',
SAGE_INTACCT_ADVANCED: 'Policy_Accounting_Sage_Intacct_Advanced',
SAGE_INTACCT_PAYMENT_ACCOUNT: 'Policy_Accounting_Sage_Intacct_Payment_Account',
+ RECONCILIATION_ACCOUNT_SETTINGS: 'Policy_Accounting_Reconciliation_Account_Settings',
},
INITIAL: 'Workspace_Initial',
PROFILE: 'Workspace_Profile',
@@ -352,7 +354,7 @@ const SCREENS = {
TAG_EDIT: 'Tag_Edit',
TAXES: 'Workspace_Taxes',
REPORT_FIELDS: 'Workspace_ReportFields',
- REPORT_FIELD_SETTINGS: 'Workspace_ReportField_Settings',
+ REPORT_FIELDS_SETTINGS: 'Workspace_ReportFields_Settings',
REPORT_FIELDS_CREATE: 'Workspace_ReportFields_Create',
REPORT_FIELDS_LIST_VALUES: 'Workspace_ReportFields_ListValues',
REPORT_FIELDS_ADD_VALUE: 'Workspace_ReportFields_AddValue',
diff --git a/src/components/AccountingListSkeletonView.tsx b/src/components/AccountingListSkeletonView.tsx
index b977903d3adc..dbe8ada6c4b7 100644
--- a/src/components/AccountingListSkeletonView.tsx
+++ b/src/components/AccountingListSkeletonView.tsx
@@ -4,12 +4,14 @@ import ItemListSkeletonView from './Skeletons/ItemListSkeletonView';
type AccountingListSkeletonViewProps = {
shouldAnimate?: boolean;
+ gradientOpacityEnabled?: boolean;
};
-function AccountingListSkeletonView({shouldAnimate = true}: AccountingListSkeletonViewProps) {
+function AccountingListSkeletonView({shouldAnimate = true, gradientOpacityEnabled = false}: AccountingListSkeletonViewProps) {
return (
(
<>
{
+ if (!event) {
+ return;
+ }
+
+ if ('naturalSize' in event) {
+ setVideoAspectRatio(event.naturalSize.width / event.naturalSize.height);
+ } else {
+ setVideoAspectRatio(event.srcElement.videoWidth / event.srcElement.videoHeight);
+ }
+ };
+
+ const HeaderComponent = useMemo(() => {
+ switch (headerMediaType) {
+ case CONST.EMPTY_STATE_MEDIA.VIDEO:
+ return (
+
+ );
+ case CONST.EMPTY_STATE_MEDIA.ANIMATION:
+ return (
+
+ );
+ case CONST.EMPTY_STATE_MEDIA.ILLUSTRATION:
+ return (
+
+ );
+ default:
+ return null;
+ }
+ }, [headerMedia, headerMediaType, headerContentStyles, videoAspectRatio, styles.emptyStateVideo]);
+
+ return (
+
+
+
+
+
+
+ {HeaderComponent}
+
+ {title}
+ {subtitle}
+ {!!buttonText && !!buttonAction && (
+
+ )}
+
+
+
+
+ );
+}
+
+EmptyStateComponent.displayName = 'EmptyStateComponent';
+export default EmptyStateComponent;
diff --git a/src/components/EmptyStateComponent/types.ts b/src/components/EmptyStateComponent/types.ts
new file mode 100644
index 000000000000..326b25542f42
--- /dev/null
+++ b/src/components/EmptyStateComponent/types.ts
@@ -0,0 +1,41 @@
+import type {ImageStyle} from 'expo-image';
+import type {StyleProp, ViewStyle} from 'react-native';
+import type {ValueOf} from 'type-fest';
+import type DotLottieAnimation from '@components/LottieAnimations/types';
+import type SearchRowSkeleton from '@components/Skeletons/SearchRowSkeleton';
+import type TableRowSkeleton from '@components/Skeletons/TableRowSkeleton';
+import type CONST from '@src/CONST';
+import type IconAsset from '@src/types/utils/IconAsset';
+
+type ValidSkeletons = typeof SearchRowSkeleton | typeof TableRowSkeleton;
+type MediaTypes = ValueOf;
+
+type SharedProps = {
+ SkeletonComponent: ValidSkeletons;
+ title: string;
+ subtitle: string;
+ buttonText?: string;
+ buttonAction?: () => void;
+ headerStyles?: StyleProp;
+ headerMediaType: T;
+ headerContentStyles?: StyleProp;
+};
+
+type MediaType = SharedProps & {
+ headerMedia: HeaderMedia;
+};
+
+type VideoProps = MediaType;
+type IllustrationProps = MediaType;
+type AnimationProps = MediaType;
+
+type EmptyStateComponentProps = VideoProps | IllustrationProps | AnimationProps;
+
+type VideoLoadedEventType = {
+ srcElement: {
+ videoWidth: number;
+ videoHeight: number;
+ };
+};
+
+export type {EmptyStateComponentProps, VideoLoadedEventType};
diff --git a/src/components/FocusTrap/FocusTrapForModal/index.web.tsx b/src/components/FocusTrap/FocusTrapForModal/index.web.tsx
index be5da8c49a78..00dcedd32aa2 100644
--- a/src/components/FocusTrap/FocusTrapForModal/index.web.tsx
+++ b/src/components/FocusTrap/FocusTrapForModal/index.web.tsx
@@ -1,6 +1,7 @@
import FocusTrap from 'focus-trap-react';
import React from 'react';
import sharedTrapStack from '@components/FocusTrap/sharedTrapStack';
+import ReportActionComposeFocusManager from '@libs/ReportActionComposeFocusManager';
import type FocusTrapForModalProps from './FocusTrapForModalProps';
function FocusTrapForModal({children, active}: FocusTrapForModalProps) {
@@ -12,6 +13,12 @@ function FocusTrapForModal({children, active}: FocusTrapForModalProps) {
clickOutsideDeactivates: true,
initialFocus: false,
fallbackFocus: document.body,
+ setReturnFocus: (element) => {
+ if (ReportActionComposeFocusManager.isFocused()) {
+ return false;
+ }
+ return element;
+ },
}}
>
{children}
diff --git a/src/components/HybridAppMiddleware.tsx b/src/components/HybridAppMiddleware.tsx
deleted file mode 100644
index 5c6934f4fc3d..000000000000
--- a/src/components/HybridAppMiddleware.tsx
+++ /dev/null
@@ -1,108 +0,0 @@
-import {useNavigation} from '@react-navigation/native';
-import type {StackNavigationProp} from '@react-navigation/stack';
-import React, {useCallback, useEffect, useMemo, useState} from 'react';
-import {NativeModules} from 'react-native';
-import useSplashScreen from '@hooks/useSplashScreen';
-import BootSplash from '@libs/BootSplash';
-import Log from '@libs/Log';
-import Navigation from '@libs/Navigation/Navigation';
-import type {RootStackParamList} from '@libs/Navigation/types';
-import * as Welcome from '@userActions/Welcome';
-import CONST from '@src/CONST';
-import type {Route} from '@src/ROUTES';
-
-type HybridAppMiddlewareProps = {
- children: React.ReactNode;
-};
-
-type HybridAppMiddlewareContextType = {
- navigateToExitUrl: (exitUrl: Route) => void;
- showSplashScreenOnNextStart: () => void;
-};
-const HybridAppMiddlewareContext = React.createContext({
- navigateToExitUrl: () => {},
- showSplashScreenOnNextStart: () => {},
-});
-
-/*
- * HybridAppMiddleware is responsible for handling BootSplash visibility correctly.
- * It is crucial to make transitions between OldDot and NewDot look smooth.
- */
-function HybridAppMiddleware(props: HybridAppMiddlewareProps) {
- const {isSplashHidden, setIsSplashHidden} = useSplashScreen();
- const [startedTransition, setStartedTransition] = useState(false);
- const [finishedTransition, setFinishedTransition] = useState(false);
- const navigation = useNavigation>();
-
- /*
- * Handles navigation during transition from OldDot. For ordinary NewDot app it is just pure navigation.
- */
- const navigateToExitUrl = useCallback((exitUrl: Route) => {
- if (NativeModules.HybridAppModule) {
- setStartedTransition(true);
- Log.info(`[HybridApp] Started transition to ${exitUrl}`, true);
- }
-
- Navigation.navigate(exitUrl);
- }, []);
-
- /**
- * This function only affects iOS. If during a single app lifecycle we frequently transition from OldDot to NewDot,
- * we need to artificially show the bootsplash because the app is only booted once.
- */
- const showSplashScreenOnNextStart = useCallback(() => {
- setIsSplashHidden(false);
- setStartedTransition(false);
- setFinishedTransition(false);
- }, [setIsSplashHidden]);
-
- useEffect(() => {
- if (!finishedTransition || isSplashHidden) {
- return;
- }
-
- Log.info('[HybridApp] Finished transtion', true);
- BootSplash.hide().then(() => {
- setIsSplashHidden(true);
- Log.info('[HybridApp] Handling onboarding flow', true);
- Welcome.handleHybridAppOnboarding();
- });
- }, [finishedTransition, isSplashHidden, setIsSplashHidden]);
-
- useEffect(() => {
- if (!startedTransition) {
- return;
- }
-
- // On iOS, the transitionEnd event doesn't trigger some times. As such, we need to set a timeout.
- const timeout = setTimeout(() => {
- setFinishedTransition(true);
- }, CONST.SCREEN_TRANSITION_END_TIMEOUT);
-
- const unsubscribeTransitionEnd = navigation.addListener('transitionEnd', () => {
- clearTimeout(timeout);
- setFinishedTransition(true);
- });
-
- return () => {
- clearTimeout(timeout);
- unsubscribeTransitionEnd();
- };
- }, [navigation, startedTransition]);
-
- const contextValue = useMemo(
- () => ({
- navigateToExitUrl,
- showSplashScreenOnNextStart,
- }),
- [navigateToExitUrl, showSplashScreenOnNextStart],
- );
-
- return {props.children};
-}
-
-HybridAppMiddleware.displayName = 'HybridAppMiddleware';
-
-export default HybridAppMiddleware;
-export type {HybridAppMiddlewareContextType};
-export {HybridAppMiddlewareContext};
diff --git a/src/components/HybridAppMiddleware/index.ios.tsx b/src/components/HybridAppMiddleware/index.ios.tsx
new file mode 100644
index 000000000000..5b06e5626c6e
--- /dev/null
+++ b/src/components/HybridAppMiddleware/index.ios.tsx
@@ -0,0 +1,130 @@
+import type React from 'react';
+import {useContext, useEffect, useState} from 'react';
+import {NativeEventEmitter, NativeModules} from 'react-native';
+import type {NativeModule} from 'react-native';
+import {useOnyx} from 'react-native-onyx';
+import {InitialURLContext} from '@components/InitialURLContextProvider';
+import useExitTo from '@hooks/useExitTo';
+import useSplashScreen from '@hooks/useSplashScreen';
+import BootSplash from '@libs/BootSplash';
+import Log from '@libs/Log';
+import Navigation from '@libs/Navigation/Navigation';
+import * as SessionUtils from '@libs/SessionUtils';
+import * as Welcome from '@userActions/Welcome';
+import CONST from '@src/CONST';
+import ONYXKEYS from '@src/ONYXKEYS';
+import type {HybridAppRoute, Route} from '@src/ROUTES';
+
+type HybridAppMiddlewareProps = {
+ authenticated: boolean;
+ children: React.ReactNode;
+};
+
+/*
+ * HybridAppMiddleware is responsible for handling BootSplash visibility correctly.
+ * It is crucial to make transitions between OldDot and NewDot look smooth.
+ * The middleware assumes that the entry point for HybridApp is the /transition route.
+ */
+function HybridAppMiddleware({children, authenticated}: HybridAppMiddlewareProps) {
+ const {isSplashHidden, setIsSplashHidden} = useSplashScreen();
+ const [startedTransition, setStartedTransition] = useState(false);
+ const [finishedTransition, setFinishedTransition] = useState(false);
+
+ const initialURL = useContext(InitialURLContext);
+ const exitToParam = useExitTo();
+ const [exitTo, setExitTo] = useState();
+
+ const [isAccountLoading] = useOnyx(ONYXKEYS.ACCOUNT, {selector: (account) => account?.isLoading ?? false});
+ const [sessionEmail] = useOnyx(ONYXKEYS.SESSION, {selector: (session) => session?.email});
+
+ // In iOS, the HybridApp defines the `onReturnToOldDot` event.
+ // If we frequently transition from OldDot to NewDot during a single app lifecycle,
+ // we need to artificially display the bootsplash since the app is booted only once.
+ // Therefore, isSplashHidden needs to be updated at the appropriate time.
+ useEffect(() => {
+ if (!NativeModules.HybridAppModule) {
+ return;
+ }
+ const HybridAppEvents = new NativeEventEmitter(NativeModules.HybridAppModule as unknown as NativeModule);
+ const listener = HybridAppEvents.addListener(CONST.EVENTS.ON_RETURN_TO_OLD_DOT, () => {
+ Log.info('[HybridApp] `onReturnToOldDot` event received. Resetting state of HybridAppMiddleware', true);
+ setIsSplashHidden(false);
+ setStartedTransition(false);
+ setFinishedTransition(false);
+ setExitTo(undefined);
+ });
+
+ return () => {
+ listener.remove();
+ };
+ }, [setIsSplashHidden]);
+
+ // Save `exitTo` when we reach /transition route.
+ // `exitTo` should always exist during OldDot -> NewDot transitions.
+ useEffect(() => {
+ if (!NativeModules.HybridAppModule || !exitToParam || exitTo) {
+ return;
+ }
+
+ Log.info('[HybridApp] Saving `exitTo` for later', true, {exitTo: exitToParam});
+ setExitTo(exitToParam);
+
+ Log.info(`[HybridApp] Started transition`, true);
+ setStartedTransition(true);
+ }, [exitTo, exitToParam]);
+
+ useEffect(() => {
+ if (!startedTransition || finishedTransition) {
+ return;
+ }
+
+ const transitionURL = NativeModules.HybridAppModule ? `${CONST.DEEPLINK_BASE_URL}${initialURL ?? ''}` : initialURL;
+ const isLoggingInAsNewUser = SessionUtils.isLoggingInAsNewUser(transitionURL ?? undefined, sessionEmail);
+
+ // We need to wait with navigating to exitTo until all login-related actions are complete.
+ if (!authenticated || isLoggingInAsNewUser || isAccountLoading) {
+ return;
+ }
+
+ if (exitTo) {
+ Navigation.isNavigationReady().then(() => {
+ // We need to remove /transition from route history.
+ // `useExitTo` returns undefined for routes other than /transition.
+ if (exitToParam) {
+ Log.info('[HybridApp] Removing /transition route from history', true);
+ Navigation.goBack();
+ }
+
+ Log.info('[HybridApp] Navigating to `exitTo` route', true, {exitTo});
+ Navigation.navigate(Navigation.parseHybridAppUrl(exitTo));
+ setExitTo(undefined);
+
+ setTimeout(() => {
+ Log.info('[HybridApp] Setting `finishedTransition` to true', true);
+ setFinishedTransition(true);
+ }, CONST.SCREEN_TRANSITION_END_TIMEOUT);
+ });
+ }
+ }, [authenticated, exitTo, exitToParam, finishedTransition, initialURL, isAccountLoading, sessionEmail, startedTransition]);
+
+ useEffect(() => {
+ if (!finishedTransition || isSplashHidden) {
+ return;
+ }
+
+ Log.info('[HybridApp] Finished transition, hiding BootSplash', true);
+ BootSplash.hide().then(() => {
+ setIsSplashHidden(true);
+ if (authenticated) {
+ Log.info('[HybridApp] Handling onboarding flow', true);
+ Welcome.handleHybridAppOnboarding();
+ }
+ });
+ }, [authenticated, finishedTransition, isSplashHidden, setIsSplashHidden]);
+
+ return children;
+}
+
+HybridAppMiddleware.displayName = 'HybridAppMiddleware';
+
+export default HybridAppMiddleware;
diff --git a/src/components/HybridAppMiddleware/index.tsx b/src/components/HybridAppMiddleware/index.tsx
new file mode 100644
index 000000000000..b8c72d9200ac
--- /dev/null
+++ b/src/components/HybridAppMiddleware/index.tsx
@@ -0,0 +1,107 @@
+import type React from 'react';
+import {useContext, useEffect, useState} from 'react';
+import {NativeModules} from 'react-native';
+import {useOnyx} from 'react-native-onyx';
+import {InitialURLContext} from '@components/InitialURLContextProvider';
+import useExitTo from '@hooks/useExitTo';
+import useSplashScreen from '@hooks/useSplashScreen';
+import BootSplash from '@libs/BootSplash';
+import Log from '@libs/Log';
+import Navigation from '@libs/Navigation/Navigation';
+import * as SessionUtils from '@libs/SessionUtils';
+import * as Welcome from '@userActions/Welcome';
+import CONST from '@src/CONST';
+import ONYXKEYS from '@src/ONYXKEYS';
+import type {HybridAppRoute, Route} from '@src/ROUTES';
+
+type HybridAppMiddlewareProps = {
+ authenticated: boolean;
+ children: React.ReactNode;
+};
+
+/*
+ * HybridAppMiddleware is responsible for handling BootSplash visibility correctly.
+ * It is crucial to make transitions between OldDot and NewDot look smooth.
+ * The middleware assumes that the entry point for HybridApp is the /transition route.
+ */
+function HybridAppMiddleware({children, authenticated}: HybridAppMiddlewareProps) {
+ const {isSplashHidden, setIsSplashHidden} = useSplashScreen();
+ const [startedTransition, setStartedTransition] = useState(false);
+ const [finishedTransition, setFinishedTransition] = useState(false);
+
+ const initialURL = useContext(InitialURLContext);
+ const exitToParam = useExitTo();
+ const [exitTo, setExitTo] = useState();
+
+ const [isAccountLoading] = useOnyx(ONYXKEYS.ACCOUNT, {selector: (account) => account?.isLoading ?? false});
+ const [sessionEmail] = useOnyx(ONYXKEYS.SESSION, {selector: (session) => session?.email});
+
+ // Save `exitTo` when we reach /transition route.
+ // `exitTo` should always exist during OldDot -> NewDot transitions.
+ useEffect(() => {
+ if (!NativeModules.HybridAppModule || !exitToParam || exitTo) {
+ return;
+ }
+
+ Log.info('[HybridApp] Saving `exitTo` for later', true, {exitTo: exitToParam});
+ setExitTo(exitToParam);
+
+ Log.info(`[HybridApp] Started transition`, true);
+ setStartedTransition(true);
+ }, [exitTo, exitToParam]);
+
+ useEffect(() => {
+ if (!startedTransition || finishedTransition) {
+ return;
+ }
+
+ const transitionURL = NativeModules.HybridAppModule ? `${CONST.DEEPLINK_BASE_URL}${initialURL ?? ''}` : initialURL;
+ const isLoggingInAsNewUser = SessionUtils.isLoggingInAsNewUser(transitionURL ?? undefined, sessionEmail);
+
+ // We need to wait with navigating to exitTo until all login-related actions are complete.
+ if (!authenticated || isLoggingInAsNewUser || isAccountLoading) {
+ return;
+ }
+
+ if (exitTo) {
+ Navigation.isNavigationReady().then(() => {
+ // We need to remove /transition from route history.
+ // `useExitTo` returns undefined for routes other than /transition.
+ if (exitToParam) {
+ Log.info('[HybridApp] Removing /transition route from history', true);
+ Navigation.goBack();
+ }
+
+ Log.info('[HybridApp] Navigating to `exitTo` route', true, {exitTo});
+ Navigation.navigate(Navigation.parseHybridAppUrl(exitTo));
+ setExitTo(undefined);
+
+ setTimeout(() => {
+ Log.info('[HybridApp] Setting `finishedTransition` to true', true);
+ setFinishedTransition(true);
+ }, CONST.SCREEN_TRANSITION_END_TIMEOUT);
+ });
+ }
+ }, [authenticated, exitTo, exitToParam, finishedTransition, initialURL, isAccountLoading, sessionEmail, startedTransition]);
+
+ useEffect(() => {
+ if (!finishedTransition || isSplashHidden) {
+ return;
+ }
+
+ Log.info('[HybridApp] Finished transition, hiding BootSplash', true);
+ BootSplash.hide().then(() => {
+ setIsSplashHidden(true);
+ if (authenticated) {
+ Log.info('[HybridApp] Handling onboarding flow', true);
+ Welcome.handleHybridAppOnboarding();
+ }
+ });
+ }, [authenticated, finishedTransition, isSplashHidden, setIsSplashHidden]);
+
+ return children;
+}
+
+HybridAppMiddleware.displayName = 'HybridAppMiddleware';
+
+export default HybridAppMiddleware;
diff --git a/src/components/Icon/Illustrations.ts b/src/components/Icon/Illustrations.ts
index 7a8186d2f38e..da72b3025340 100644
--- a/src/components/Icon/Illustrations.ts
+++ b/src/components/Icon/Illustrations.ts
@@ -53,6 +53,7 @@ import ConciergeNew from '@assets/images/simple-illustrations/simple-illustratio
import CreditCardsNew from '@assets/images/simple-illustrations/simple-illustration__credit-cards.svg';
import CreditCardEyes from '@assets/images/simple-illustrations/simple-illustration__creditcardeyes.svg';
import EmailAddress from '@assets/images/simple-illustrations/simple-illustration__email-address.svg';
+import EmptyState from '@assets/images/simple-illustrations/simple-illustration__empty-state.svg';
import FolderOpen from '@assets/images/simple-illustrations/simple-illustration__folder-open.svg';
import Gears from '@assets/images/simple-illustrations/simple-illustration__gears.svg';
import HandCard from '@assets/images/simple-illustrations/simple-illustration__handcard.svg';
@@ -198,6 +199,7 @@ export {
CheckmarkCircle,
CreditCardEyes,
LockClosedOrange,
+ EmptyState,
FolderWithPapers,
VirtualCard,
};
diff --git a/src/components/MoneyReportHeader.tsx b/src/components/MoneyReportHeader.tsx
index 80ad2890afaa..6777bbf6c269 100644
--- a/src/components/MoneyReportHeader.tsx
+++ b/src/components/MoneyReportHeader.tsx
@@ -99,7 +99,6 @@ function MoneyReportHeader({policy, report: moneyRequestReport, transactionThrea
const canAllowSettlement = ReportUtils.hasUpdatedTotal(moneyRequestReport, policy);
const policyType = policy?.type;
const isDraft = ReportUtils.isOpenExpenseReport(moneyRequestReport);
- const [isConfirmModalVisible, setIsConfirmModalVisible] = useState(false);
const navigateBackToAfterDelete = useRef();
const hasScanningReceipt = ReportUtils.getTransactionsWithReceipts(moneyRequestReport?.reportID).some((t) => TransactionUtils.isReceiptBeingScanned(t));
@@ -108,14 +107,6 @@ function MoneyReportHeader({policy, report: moneyRequestReport, transactionThrea
// allTransactions in TransactionUtils might have stale data
const hasOnlyHeldExpenses = ReportUtils.hasOnlyHeldExpenses(moneyRequestReport.reportID, transactions);
- const cancelPayment = useCallback(() => {
- if (!chatReport) {
- return;
- }
- IOU.cancelPayment(moneyRequestReport, chatReport);
- setIsConfirmModalVisible(false);
- }, [moneyRequestReport, chatReport]);
-
const shouldShowPayButton = useMemo(() => IOU.canIOUBePaid(moneyRequestReport, chatReport, policy), [moneyRequestReport, chatReport, policy]);
const shouldShowApproveButton = useMemo(() => IOU.canApproveIOU(moneyRequestReport, chatReport, policy), [moneyRequestReport, chatReport, policy]);
@@ -371,17 +362,6 @@ function MoneyReportHeader({policy, report: moneyRequestReport, transactionThrea
transactionCount={transactionIDs.length}
/>
)}
- setIsConfirmModalVisible(false)}
- prompt={translate('iou.cancelPaymentConfirmation')}
- confirmText={translate('iou.cancelPayment')}
- cancelText={translate('common.dismiss')}
- danger
- shouldEnableNewFocusManagement
- />
{
const lineWidth = getLinedWidth(itemIndex);
diff --git a/src/components/PopoverWithoutOverlay/index.tsx b/src/components/PopoverWithoutOverlay/index.tsx
index bcead42a64f2..7d58ad6d22be 100644
--- a/src/components/PopoverWithoutOverlay/index.tsx
+++ b/src/components/PopoverWithoutOverlay/index.tsx
@@ -124,6 +124,7 @@ function PopoverWithoutOverlay(
ref={viewRef(withoutOverlayRef)}
// Prevent the parent element to capture a click. This is useful when the modal component is put inside a pressable.
onClick={(e) => e.stopPropagation()}
+ dataSet={{dragArea: false}}
>
);
diff --git a/src/components/ReportActionItem/TaskView.tsx b/src/components/ReportActionItem/TaskView.tsx
index 43e896fe6578..941e7cd5c610 100644
--- a/src/components/ReportActionItem/TaskView.tsx
+++ b/src/components/ReportActionItem/TaskView.tsx
@@ -22,6 +22,7 @@ import getButtonState from '@libs/getButtonState';
import Navigation from '@libs/Navigation/Navigation';
import * as OptionsListUtils from '@libs/OptionsListUtils';
import * as ReportUtils from '@libs/ReportUtils';
+import * as TaskUtils from '@libs/TaskUtils';
import * as Session from '@userActions/Session';
import * as Task from '@userActions/Task';
import CONST from '@src/CONST';
@@ -96,6 +97,10 @@ function TaskView({report, ...props}: TaskViewProps) {
{
+ // If we're already navigating to these task editing pages, early return not to mark as completed, otherwise we would have not found page.
+ if (TaskUtils.isActiveTaskEditRoute(report.reportID)) {
+ return;
+ }
if (isCompleted) {
Task.reopenTask(report);
} else {
diff --git a/src/components/Search/SearchContext.tsx b/src/components/Search/SearchContext.tsx
new file mode 100644
index 000000000000..3911780d3965
--- /dev/null
+++ b/src/components/Search/SearchContext.tsx
@@ -0,0 +1,58 @@
+import React, {useCallback, useContext, useMemo, useState} from 'react';
+import type ChildrenProps from '@src/types/utils/ChildrenProps';
+import type {SearchContext} from './types';
+
+const defaultSearchContext = {
+ currentSearchHash: -1,
+ selectedTransactionIDs: [],
+ setCurrentSearchHash: () => {},
+ setSelectedTransactionIds: () => {},
+};
+
+const Context = React.createContext(defaultSearchContext);
+
+function SearchContextProvider({children}: ChildrenProps) {
+ const [searchContextData, setSearchContextData] = useState>({
+ currentSearchHash: defaultSearchContext.currentSearchHash,
+ selectedTransactionIDs: defaultSearchContext.selectedTransactionIDs,
+ });
+
+ const setCurrentSearchHash = useCallback(
+ (searchHash: number) => {
+ setSearchContextData({
+ ...searchContextData,
+ currentSearchHash: searchHash,
+ });
+ },
+ [searchContextData],
+ );
+
+ const setSelectedTransactionIds = useCallback(
+ (selectedTransactionIDs: string[]) => {
+ setSearchContextData({
+ ...searchContextData,
+ selectedTransactionIDs,
+ });
+ },
+ [searchContextData],
+ );
+
+ const searchContext = useMemo(
+ () => ({
+ ...searchContextData,
+ setCurrentSearchHash,
+ setSelectedTransactionIds,
+ }),
+ [searchContextData, setCurrentSearchHash, setSelectedTransactionIds],
+ );
+
+ return {children};
+}
+
+function useSearchContext() {
+ return useContext(Context);
+}
+
+SearchContextProvider.displayName = 'SearchContextProvider';
+
+export {SearchContextProvider, useSearchContext};
diff --git a/src/components/Search/index.tsx b/src/components/Search/index.tsx
index 8445cb3bc72e..7d54d65b310e 100644
--- a/src/components/Search/index.tsx
+++ b/src/components/Search/index.tsx
@@ -1,11 +1,12 @@
import {useNavigation} from '@react-navigation/native';
import type {StackNavigationProp} from '@react-navigation/stack';
+import lodashMemoize from 'lodash/memoize';
import React, {useCallback, useEffect, useRef} from 'react';
import type {OnyxEntry} from 'react-native-onyx';
import {useOnyx} from 'react-native-onyx';
import SearchTableHeader from '@components/SelectionList/SearchTableHeader';
import type {ReportListItemType, TransactionListItemType} from '@components/SelectionList/types';
-import TableListItemSkeleton from '@components/Skeletons/TableListItemSkeleton';
+import SearchRowSkeleton from '@components/Skeletons/SearchRowSkeleton';
import useNetwork from '@hooks/useNetwork';
import useThemeStyles from '@hooks/useThemeStyles';
import useWindowDimensions from '@hooks/useWindowDimensions';
@@ -13,7 +14,6 @@ import * as SearchActions from '@libs/actions/Search';
import * as DeviceCapabilities from '@libs/DeviceCapabilities';
import Log from '@libs/Log';
import * as ReportUtils from '@libs/ReportUtils';
-import type {SearchColumnType, SortOrder} from '@libs/SearchUtils';
import * as SearchUtils from '@libs/SearchUtils';
import Navigation from '@navigation/Navigation';
import type {AuthScreensParamList} from '@navigation/types';
@@ -25,8 +25,10 @@ import ROUTES from '@src/ROUTES';
import type SearchResults from '@src/types/onyx/SearchResults';
import type {SearchDataTypes, SearchQuery} from '@src/types/onyx/SearchResults';
import isLoadingOnyxValue from '@src/types/utils/isLoadingOnyxValue';
+import {useSearchContext} from './SearchContext';
import SearchListWithHeader from './SearchListWithHeader';
import SearchPageHeader from './SearchPageHeader';
+import type {SearchColumnType, SortOrder} from './types';
type SearchProps = {
query: SearchQuery;
@@ -47,6 +49,10 @@ function Search({query, policyIDs, sortBy, sortOrder}: SearchProps) {
const {isLargeScreenWidth} = useWindowDimensions();
const navigation = useNavigation>();
const lastSearchResultsRef = useRef>();
+ const {setCurrentSearchHash} = useSearchContext();
+
+ const hash = SearchUtils.getQueryHash(query, policyIDs, sortBy, sortOrder);
+ const [currentSearchResults, searchResultsMeta] = useOnyx(`${ONYXKEYS.COLLECTION.SNAPSHOT}${hash}`);
const getItemHeight = useCallback(
(item: TransactionListItemType | ReportListItemType) => {
@@ -68,8 +74,15 @@ function Search({query, policyIDs, sortBy, sortOrder}: SearchProps) {
[isLargeScreenWidth],
);
- const hash = SearchUtils.getQueryHash(query, policyIDs, sortBy, sortOrder);
- const [currentSearchResults, searchResultsMeta] = useOnyx(`${ONYXKEYS.COLLECTION.SNAPSHOT}${hash}`);
+ const getItemHeightMemoized = lodashMemoize(
+ (item: TransactionListItemType | ReportListItemType) => getItemHeight(item),
+ (item) => {
+ // List items are displayed differently on "L"arge and "N"arrow screens so the height will differ
+ // in addition the same items might be displayed as part of different Search screens ("Expenses", "All", "Finished")
+ const screenSizeHash = isLargeScreenWidth ? 'L' : 'N';
+ return `${hash}-${item.keyForList}-${screenSizeHash}`;
+ },
+ );
// save last non-empty search results to avoid ugly flash of loading screen when hash changes and onyx returns empty data
if (currentSearchResults?.data && currentSearchResults !== lastSearchResultsRef.current) {
@@ -83,6 +96,7 @@ function Search({query, policyIDs, sortBy, sortOrder}: SearchProps) {
return;
}
+ setCurrentSearchHash(hash);
SearchActions.search({hash, query, policyIDs, offset: 0, sortBy, sortOrder});
// eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps
}, [hash, isOffline]);
@@ -98,7 +112,7 @@ function Search({query, policyIDs, sortBy, sortOrder}: SearchProps) {
query={query}
hash={hash}
/>
-
+
>
);
}
@@ -194,17 +208,17 @@ function Search({query, policyIDs, sortBy, sortOrder}: SearchProps) {
updateCellsBatchingPeriod={200}
ListItem={ListItem}
onSelectRow={openReport}
- getItemHeight={getItemHeight}
+ getItemHeight={getItemHeightMemoized}
shouldDebounceRowSelect
shouldPreventDefaultFocusOnSelectRow={!DeviceCapabilities.canUseTouchScreen()}
- listHeaderWrapperStyle={[styles.ph9, styles.pv3, styles.pb5]}
+ listHeaderWrapperStyle={[styles.ph8, styles.pv3, styles.pb5]}
containerStyle={[styles.pv0]}
showScrollIndicator={false}
onEndReachedThreshold={0.75}
onEndReached={fetchMoreResults}
listFooterContent={
isLoadingMoreItems ? (
-
diff --git a/src/components/Search/types.ts b/src/components/Search/types.ts
index 3ebc2797947a..cff74fe08a0a 100644
--- a/src/components/Search/types.ts
+++ b/src/components/Search/types.ts
@@ -1,3 +1,6 @@
+import type {ValueOf} from 'react-native-gesture-handler/lib/typescript/typeUtils';
+import type CONST from '@src/CONST';
+
/** Model of the selected transaction */
type SelectedTransactionInfo = {
/** Whether the transaction is selected */
@@ -13,5 +16,14 @@ type SelectedTransactionInfo = {
/** Model of selected results */
type SelectedTransactions = Record;
-// eslint-disable-next-line import/prefer-default-export
-export type {SelectedTransactionInfo, SelectedTransactions};
+type SortOrder = ValueOf;
+type SearchColumnType = ValueOf;
+
+type SearchContext = {
+ currentSearchHash: number;
+ selectedTransactionIDs: string[];
+ setCurrentSearchHash: (hash: number) => void;
+ setSelectedTransactionIds: (selectedTransactionIds: string[]) => void;
+};
+
+export type {SelectedTransactionInfo, SelectedTransactions, SearchColumnType, SortOrder, SearchContext};
diff --git a/src/components/SelectionList/Search/ActionCell.tsx b/src/components/SelectionList/Search/ActionCell.tsx
index 5af3d84bf32f..ad77070c1b99 100644
--- a/src/components/SelectionList/Search/ActionCell.tsx
+++ b/src/components/SelectionList/Search/ActionCell.tsx
@@ -1,46 +1,78 @@
-import React from 'react';
+import React, {useCallback} from 'react';
import {View} from 'react-native';
import Badge from '@components/Badge';
import Button from '@components/Button';
import * as Expensicons from '@components/Icon/Expensicons';
+import {useSearchContext} from '@components/Search/SearchContext';
import useLocalize from '@hooks/useLocalize';
import useStyleUtils from '@hooks/useStyleUtils';
import useTheme from '@hooks/useTheme';
import useThemeStyles from '@hooks/useThemeStyles';
+import Navigation from '@navigation/Navigation';
import variables from '@styles/variables';
+import * as SearchActions from '@userActions/Search';
import CONST from '@src/CONST';
+import type {TranslationPaths} from '@src/languages/types';
+import ROUTES from '@src/ROUTES';
+import type {SearchTransactionAction} from '@src/types/onyx/SearchResults';
+
+const actionTranslationsMap: Record = {
+ view: 'common.view',
+ review: 'common.review',
+ done: 'common.done',
+ paid: 'iou.settledExpensify',
+ hold: 'iou.hold',
+ unhold: 'iou.unhold',
+};
type ActionCellProps = {
- onButtonPress: () => void;
- action?: string;
+ action?: SearchTransactionAction;
+ transactionID?: string;
isLargeScreenWidth?: boolean;
isSelected?: boolean;
+ goToItem: () => void;
};
-function ActionCell({onButtonPress, action = CONST.SEARCH.ACTION_TYPES.VIEW, isLargeScreenWidth = true, isSelected = false}: ActionCellProps) {
+function ActionCell({action = CONST.SEARCH.ACTION_TYPES.VIEW, transactionID, isLargeScreenWidth = true, isSelected = false, goToItem}: ActionCellProps) {
const {translate} = useLocalize();
- const styles = useThemeStyles();
const theme = useTheme();
+ const styles = useThemeStyles();
const StyleUtils = useStyleUtils();
+
+ const {currentSearchHash} = useSearchContext();
+
+ const onButtonPress = useCallback(() => {
+ if (!transactionID) {
+ return;
+ }
+
+ if (action === CONST.SEARCH.ACTION_TYPES.HOLD) {
+ Navigation.navigate(ROUTES.TRANSACTION_HOLD_REASON_RHP.getRoute(CONST.SEARCH.TAB.ALL, transactionID));
+ } else if (action === CONST.SEARCH.ACTION_TYPES.UNHOLD) {
+ SearchActions.unholdMoneyRequestOnSearch(currentSearchHash, [transactionID]);
+ }
+ }, [action, currentSearchHash, transactionID]);
+
if (!isLargeScreenWidth) {
return null;
}
+ const text = translate(actionTranslationsMap[action]);
+
if (action === CONST.SEARCH.ACTION_TYPES.PAID || action === CONST.SEARCH.ACTION_TYPES.DONE) {
- const buttonTextKey = action === CONST.SEARCH.ACTION_TYPES.PAID ? 'iou.settledExpensify' : 'common.done';
return (
+ );
+ }
+
return (
);
}
diff --git a/src/components/SelectionList/Search/ExpenseItemHeaderNarrow.tsx b/src/components/SelectionList/Search/ExpenseItemHeaderNarrow.tsx
index 8f46a5388da8..f634f84509b1 100644
--- a/src/components/SelectionList/Search/ExpenseItemHeaderNarrow.tsx
+++ b/src/components/SelectionList/Search/ExpenseItemHeaderNarrow.tsx
@@ -6,7 +6,7 @@ import useStyleUtils from '@hooks/useStyleUtils';
import useTheme from '@hooks/useTheme';
import useThemeStyles from '@hooks/useThemeStyles';
import variables from '@styles/variables';
-import type {SearchAccountDetails} from '@src/types/onyx/SearchResults';
+import type {SearchAccountDetails, SearchTransactionAction} from '@src/types/onyx/SearchResults';
import ActionCell from './ActionCell';
import UserInfoCell from './UserInfoCell';
@@ -15,11 +15,12 @@ type ExpenseItemHeaderNarrowProps = {
participantTo: SearchAccountDetails;
participantFromDisplayName: string;
participantToDisplayName: string;
+ action?: SearchTransactionAction;
+ transactionID?: string;
onButtonPress: () => void;
- action?: string;
};
-function ExpenseItemHeaderNarrow({participantFrom, participantFromDisplayName, participantTo, participantToDisplayName, onButtonPress, action}: ExpenseItemHeaderNarrowProps) {
+function ExpenseItemHeaderNarrow({participantFrom, participantFromDisplayName, participantTo, participantToDisplayName, action, transactionID, onButtonPress}: ExpenseItemHeaderNarrowProps) {
const styles = useThemeStyles();
const StyleUtils = useStyleUtils();
const theme = useTheme();
@@ -48,9 +49,9 @@ function ExpenseItemHeaderNarrow({participantFrom, participantFromDisplayName, p
diff --git a/src/components/SelectionList/Search/ReportListItem.tsx b/src/components/SelectionList/Search/ReportListItem.tsx
index 7119cee06cd9..29c7bc2ca60c 100644
--- a/src/components/SelectionList/Search/ReportListItem.tsx
+++ b/src/components/SelectionList/Search/ReportListItem.tsx
@@ -145,7 +145,7 @@ function ReportListItem({
onButtonPress={handleOnButtonPress}
/>
)}
-
+
{canSelectMultiple && (
@@ -177,9 +177,8 @@ function ReportListItem({
diff --git a/src/components/SelectionList/Search/TransactionListItem.tsx b/src/components/SelectionList/Search/TransactionListItem.tsx
index b00ae0703c2e..6db308831baa 100644
--- a/src/components/SelectionList/Search/TransactionListItem.tsx
+++ b/src/components/SelectionList/Search/TransactionListItem.tsx
@@ -22,7 +22,7 @@ function TransactionListItem({
const {isLargeScreenWidth} = useWindowDimensions();
- const listItemPressableStyle = [styles.selectionListPressableItemWrapper, styles.pv3, item.isSelected && styles.activeComponentBG, isFocused && styles.sidebarLinkActive];
+ const listItemPressableStyle = [styles.selectionListPressableItemWrapper, styles.pv3, styles.ph3, item.isSelected && styles.activeComponentBG, isFocused && styles.sidebarLinkActive];
const listItemWrapperStyle = [
styles.flex1,
diff --git a/src/components/SelectionList/Search/TransactionListItemRow.tsx b/src/components/SelectionList/Search/TransactionListItemRow.tsx
index 23f9234819c3..f9ca70536e4b 100644
--- a/src/components/SelectionList/Search/TransactionListItemRow.tsx
+++ b/src/components/SelectionList/Search/TransactionListItemRow.tsx
@@ -72,13 +72,15 @@ function ReceiptCell({transactionItem}: TransactionCellProps) {
const styles = useThemeStyles();
const StyleUtils = useStyleUtils();
+ const backgroundStyles = transactionItem.isSelected ? StyleUtils.getBackgroundColorStyle(theme.buttonHoveredBG) : StyleUtils.getBackgroundColorStyle(theme.border);
+
return (
@@ -251,8 +254,9 @@ function TransactionListItemRow({
participantFromDisplayName={item.formattedFrom}
participantTo={item.to}
participantToDisplayName={item.formattedTo}
- onButtonPress={onButtonPress}
action={item.action}
+ transactionID={item.transactionID}
+ onButtonPress={onButtonPress}
/>
)}
@@ -314,7 +318,7 @@ function TransactionListItemRow({
style={[styles.cursorUnset, StyleUtils.getCheckboxPressableStyle(), item.isDisabledCheckbox && styles.cursorDisabled]}
/>
)}
-
+
diff --git a/src/components/SelectionList/SearchTableHeader.tsx b/src/components/SelectionList/SearchTableHeader.tsx
index 95e4b680692b..235cff294f8f 100644
--- a/src/components/SelectionList/SearchTableHeader.tsx
+++ b/src/components/SelectionList/SearchTableHeader.tsx
@@ -1,11 +1,11 @@
import React from 'react';
import {View} from 'react-native';
+import type {SearchColumnType, SortOrder} from '@components/Search/types';
import useLocalize from '@hooks/useLocalize';
import useStyleUtils from '@hooks/useStyleUtils';
import useThemeStyles from '@hooks/useThemeStyles';
import useWindowDimensions from '@hooks/useWindowDimensions';
import * as SearchUtils from '@libs/SearchUtils';
-import type {SearchColumnType, SortOrder} from '@libs/SearchUtils';
import CONST from '@src/CONST';
import type {TranslationPaths} from '@src/languages/types';
import type * as OnyxTypes from '@src/types/onyx';
@@ -108,7 +108,7 @@ function SearchTableHeader({data, metadata, sortBy, sortOrder, isSortingAllowed,
return (
-
+
{SearchColumns.map(({columnName, translationKey, shouldShow, isColumnSortable}) => {
if (!shouldShow(data, metadata)) {
return null;
diff --git a/src/components/SelectionList/SortableHeaderText.tsx b/src/components/SelectionList/SortableHeaderText.tsx
index 8b0accf45711..47a894d79f53 100644
--- a/src/components/SelectionList/SortableHeaderText.tsx
+++ b/src/components/SelectionList/SortableHeaderText.tsx
@@ -4,10 +4,10 @@ import type {StyleProp, TextStyle, ViewStyle} from 'react-native';
import Icon from '@components/Icon';
import * as Expensicons from '@components/Icon/Expensicons';
import PressableWithFeedback from '@components/Pressable/PressableWithFeedback';
+import type {SortOrder} from '@components/Search/types';
import Text from '@components/Text';
import useTheme from '@hooks/useTheme';
import useThemeStyles from '@hooks/useThemeStyles';
-import type {SortOrder} from '@libs/SearchUtils';
import CONST from '@src/CONST';
type SearchTableHeaderColumnProps = {
diff --git a/src/components/Skeletons/ItemListSkeletonView.tsx b/src/components/Skeletons/ItemListSkeletonView.tsx
index 1ee2da8a8019..046cdfffbee5 100644
--- a/src/components/Skeletons/ItemListSkeletonView.tsx
+++ b/src/components/Skeletons/ItemListSkeletonView.tsx
@@ -1,6 +1,6 @@
-import React, {useMemo, useState} from 'react';
-import {View} from 'react-native';
-import type {StyleProp, ViewStyle} from 'react-native';
+import React, {useCallback, useMemo, useState} from 'react';
+import type {LayoutChangeEvent, StyleProp, ViewStyle} from 'react-native';
+import {StyleSheet, View} from 'react-native';
import SkeletonViewContentLoader from '@components/SkeletonViewContentLoader';
import useTheme from '@hooks/useTheme';
import useThemeStyles from '@hooks/useThemeStyles';
@@ -10,22 +10,62 @@ type ListItemSkeletonProps = {
shouldAnimate?: boolean;
renderSkeletonItem: (args: {itemIndex: number}) => React.ReactNode;
fixedNumItems?: number;
+ gradientOpacityEnabled?: boolean;
itemViewStyle?: StyleProp;
itemViewHeight?: number;
};
-function ItemListSkeletonView({shouldAnimate = true, renderSkeletonItem, fixedNumItems, itemViewStyle = {}, itemViewHeight = CONST.LHN_SKELETON_VIEW_ITEM_HEIGHT}: ListItemSkeletonProps) {
+const getVerticalMargin = (style: StyleProp): number => {
+ if (!style) {
+ return 0;
+ }
+
+ const flattenStyle = StyleSheet.flatten(style);
+ const marginVertical = Number(flattenStyle?.marginVertical ?? 0);
+ const marginTop = Number(flattenStyle?.marginTop ?? 0);
+ const marginBottom = Number(flattenStyle?.marginBottom ?? 0);
+
+ return marginVertical + marginTop + marginBottom;
+};
+
+function ItemListSkeletonView({
+ shouldAnimate = true,
+ renderSkeletonItem,
+ fixedNumItems,
+ gradientOpacityEnabled = false,
+ itemViewStyle = {},
+ itemViewHeight = CONST.LHN_SKELETON_VIEW_ITEM_HEIGHT,
+}: ListItemSkeletonProps) {
const theme = useTheme();
const themeStyles = useThemeStyles();
const [numItems, setNumItems] = useState(fixedNumItems ?? 0);
+
+ const totalItemHeight = itemViewHeight + getVerticalMargin(itemViewStyle);
+
+ const handleLayout = useCallback(
+ (event: LayoutChangeEvent) => {
+ if (fixedNumItems) {
+ return;
+ }
+
+ const totalHeight = event.nativeEvent.layout.height;
+ const newNumItems = Math.ceil(totalHeight / totalItemHeight);
+ if (newNumItems !== numItems) {
+ setNumItems(newNumItems);
+ }
+ },
+ [fixedNumItems, numItems, totalItemHeight],
+ );
+
const skeletonViewItems = useMemo(() => {
const items = [];
for (let i = 0; i < numItems; i++) {
+ const opacity = gradientOpacityEnabled ? 1 - i / (numItems - 1) : 1;
items.push(
{
- if (fixedNumItems) {
- return;
- }
-
- const newNumItems = Math.ceil(event.nativeEvent.layout.height / itemViewHeight);
- if (newNumItems === numItems) {
- return;
- }
- setNumItems(newNumItems);
- }}
+ onLayout={handleLayout}
>
- {skeletonViewItems}
+ {skeletonViewItems}
);
}
diff --git a/src/components/Skeletons/TableListItemSkeleton.tsx b/src/components/Skeletons/SearchRowSkeleton.tsx
similarity index 54%
rename from src/components/Skeletons/TableListItemSkeleton.tsx
rename to src/components/Skeletons/SearchRowSkeleton.tsx
index 6ff3a3aedbb9..2359e47b7520 100644
--- a/src/components/Skeletons/TableListItemSkeleton.tsx
+++ b/src/components/Skeletons/SearchRowSkeleton.tsx
@@ -2,26 +2,41 @@ import React from 'react';
import {Circle, Rect} from 'react-native-svg';
import useThemeStyles from '@hooks/useThemeStyles';
import useWindowDimensions from '@hooks/useWindowDimensions';
+import variables from '@styles/variables';
import CONST from '@src/CONST';
import ItemListSkeletonView from './ItemListSkeletonView';
-type TableListItemSkeletonProps = {
+type SearchRowSkeletonProps = {
shouldAnimate?: boolean;
fixedNumItems?: number;
+ gradientOpacityEnabled?: boolean;
};
-const barHeight = '10';
-const shortBarWidth = '40';
-const longBarWidth = '120';
+const barHeight = 8;
+const longBarWidth = 120;
+const leftPaneWidth = variables.sideBarWidth;
-function TableListItemSkeleton({shouldAnimate = true, fixedNumItems}: TableListItemSkeletonProps) {
+// 12 is the gap between the element and the right button
+const gapWidth = 12;
+
+// 80 is the width of the element itself
+const rightSideElementWidth = 80;
+
+// 24 is the padding of the central pane summing two sides
+const centralPanePadding = 40;
+
+// 80 is the width of the button on the right side
+const rightButtonWidth = 80;
+
+function SearchRowSkeleton({shouldAnimate = true, fixedNumItems, gradientOpacityEnabled = false}: SearchRowSkeletonProps) {
const styles = useThemeStyles();
- const {windowWidth, isSmallScreenWidth} = useWindowDimensions();
+ const {windowWidth, isSmallScreenWidth, isLargeScreenWidth} = useWindowDimensions();
if (isSmallScreenWidth) {
return (
(
@@ -51,7 +66,7 @@ function TableListItemSkeleton({shouldAnimate = true, fixedNumItems}: TableListI
height={4}
/>
);
}
+
return (
(
<>
-
+ {isLargeScreenWidth && (
+ <>
+
+
+
+ >
+ )}
+
+
>
)}
@@ -146,6 +181,6 @@ function TableListItemSkeleton({shouldAnimate = true, fixedNumItems}: TableListI
);
}
-TableListItemSkeleton.displayName = 'TableListItemSkeleton';
+SearchRowSkeleton.displayName = 'SearchRowSkeleton';
-export default TableListItemSkeleton;
+export default SearchRowSkeleton;
diff --git a/src/components/Skeletons/TableRowSkeleton.tsx b/src/components/Skeletons/TableRowSkeleton.tsx
new file mode 100644
index 000000000000..865bffc5842f
--- /dev/null
+++ b/src/components/Skeletons/TableRowSkeleton.tsx
@@ -0,0 +1,52 @@
+import React from 'react';
+import {Circle, Rect} from 'react-native-svg';
+import useThemeStyles from '@hooks/useThemeStyles';
+import ItemListSkeletonView from './ItemListSkeletonView';
+
+type TableListItemSkeletonProps = {
+ shouldAnimate?: boolean;
+ fixedNumItems?: number;
+ gradientOpacityEnabled?: boolean;
+};
+
+const barHeight = '8';
+const shortBarWidth = '60';
+const longBarWidth = '124';
+
+function TableListItemSkeleton({shouldAnimate = true, fixedNumItems, gradientOpacityEnabled = false}: TableListItemSkeletonProps) {
+ const styles = useThemeStyles();
+
+ return (
+ (
+ <>
+
+
+
+ >
+ )}
+ />
+ );
+}
+
+TableListItemSkeleton.displayName = 'TableListItemSkeleton';
+
+export default TableListItemSkeleton;
diff --git a/src/components/StateSelector.tsx b/src/components/StateSelector.tsx
index 7e598a929ae3..e2be9281d0bb 100644
--- a/src/components/StateSelector.tsx
+++ b/src/components/StateSelector.tsx
@@ -89,7 +89,7 @@ function StateSelector(
brickRoadIndicator={errorText ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined}
errorText={errorText}
onPress={() => {
- const activeRoute = Navigation.getActiveRouteWithoutParams();
+ const activeRoute = Navigation.getActiveRoute();
didOpenStateSelector.current = true;
Navigation.navigate(stateSelectorRoute.getRoute(stateCode, activeRoute, label));
}}
diff --git a/src/components/TaskHeaderActionButton.tsx b/src/components/TaskHeaderActionButton.tsx
index 0c7e603a4aa2..5e563ea99763 100644
--- a/src/components/TaskHeaderActionButton.tsx
+++ b/src/components/TaskHeaderActionButton.tsx
@@ -5,6 +5,7 @@ import {withOnyx} from 'react-native-onyx';
import useLocalize from '@hooks/useLocalize';
import useThemeStyles from '@hooks/useThemeStyles';
import * as ReportUtils from '@libs/ReportUtils';
+import * as TaskUtils from '@libs/TaskUtils';
import * as Session from '@userActions/Session';
import * as Task from '@userActions/Task';
import ONYXKEYS from '@src/ONYXKEYS';
@@ -36,7 +37,17 @@ function TaskHeaderActionButton({report, session}: TaskHeaderActionButtonProps)
isDisabled={!Task.canModifyTask(report, session?.accountID ?? -1)}
medium
text={translate(ReportUtils.isCompletedTaskReport(report) ? 'task.markAsIncomplete' : 'task.markAsComplete')}
- onPress={Session.checkIfActionIsAllowed(() => (ReportUtils.isCompletedTaskReport(report) ? Task.reopenTask(report) : Task.completeTask(report)))}
+ onPress={Session.checkIfActionIsAllowed(() => {
+ // If we're already navigating to these task editing pages, early return not to mark as completed, otherwise we would have not found page.
+ if (TaskUtils.isActiveTaskEditRoute(report.reportID)) {
+ return;
+ }
+ if (ReportUtils.isCompletedTaskReport(report)) {
+ Task.reopenTask(report);
+ } else {
+ Task.completeTask(report);
+ }
+ })}
style={styles.flex1}
/>
diff --git a/src/components/TextPicker/TextSelectorModal.tsx b/src/components/TextPicker/TextSelectorModal.tsx
index 2016133559d4..bea95df3dbfe 100644
--- a/src/components/TextPicker/TextSelectorModal.tsx
+++ b/src/components/TextPicker/TextSelectorModal.tsx
@@ -1,6 +1,7 @@
import {useFocusEffect} from '@react-navigation/native';
-import React, {useCallback, useRef, useState} from 'react';
+import React, {useCallback, useEffect, useRef, useState} from 'react';
import {Keyboard, View} from 'react-native';
+import type {TextInput as TextInputType} from 'react-native';
import Button from '@components/Button';
import HeaderWithBackButton from '@components/HeaderWithBackButton';
import Modal from '@components/Modal';
@@ -23,6 +24,7 @@ function TextSelectorModal({value, description = '', subtitle, onValueSelected,
const paddingStyle = usePaddingStyle();
const inputRef = useRef(null);
+ const inputValueRef = useRef(value);
const focusTimeoutRef = useRef(null);
const hide = useCallback(() => {
@@ -32,11 +34,16 @@ function TextSelectorModal({value, description = '', subtitle, onValueSelected,
}
}, [onClose, shouldClearOnClose]);
+ useEffect(() => {
+ inputValueRef.current = currentValue;
+ }, [currentValue]);
+
useFocusEffect(
useCallback(() => {
focusTimeoutRef.current = setTimeout(() => {
if (inputRef.current && isVisible) {
inputRef.current.focus();
+ (inputRef.current as TextInputType).setSelection?.(inputValueRef.current?.length ?? 0, inputValueRef.current?.length ?? 0);
}
return () => {
if (!focusTimeoutRef.current || !isVisible) {
diff --git a/src/components/ThumbnailImage.tsx b/src/components/ThumbnailImage.tsx
index 3b89b7c3a7ad..04d0200ea228 100644
--- a/src/components/ThumbnailImage.tsx
+++ b/src/components/ThumbnailImage.tsx
@@ -41,9 +41,12 @@ type ThumbnailImageProps = {
/** The size of the fallback icon */
fallbackIconSize?: number;
- /** The colod of the fallback icon */
+ /** The color of the fallback icon */
fallbackIconColor?: string;
+ /** The background color of fallback icon */
+ fallbackIconBackground?: string;
+
/** Should the image be resized on load or just fit container */
shouldDynamicallyResize?: boolean;
@@ -66,6 +69,7 @@ function ThumbnailImage({
fallbackIcon = Expensicons.Gallery,
fallbackIconSize = variables.iconSizeSuperLarge,
fallbackIconColor,
+ fallbackIconBackground,
objectPosition = CONST.IMAGE_OBJECT_POSITION.INITIAL,
}: ThumbnailImageProps) {
const styles = useThemeStyles();
@@ -107,8 +111,10 @@ function ThumbnailImage({
const sizeStyles = shouldDynamicallyResize ? [thumbnailDimensionsStyles] : [styles.w100, styles.h100];
if (failedToLoad || previewSourceURL === '') {
+ const fallbackColor = StyleUtils.getBackgroundColorStyle(fallbackIconBackground ?? theme.border);
+
return (
-
+
((state) => {
+ const focusedRoute = findFocusedRoute(state);
+
+ if (focusedRoute?.name !== SCREENS.TRANSITION_BETWEEN_APPS) {
+ return undefined;
+ }
+
+ return focusedRoute?.params as PublicScreensParamList[typeof SCREENS.TRANSITION_BETWEEN_APPS];
+ });
+
+ return activeRouteParams?.exitTo;
+}
diff --git a/src/hooks/useHybridAppMiddleware.ts b/src/hooks/useHybridAppMiddleware.ts
deleted file mode 100644
index 18ebd9730630..000000000000
--- a/src/hooks/useHybridAppMiddleware.ts
+++ /dev/null
@@ -1,11 +0,0 @@
-import {useContext} from 'react';
-import {HybridAppMiddlewareContext} from '@components/HybridAppMiddleware';
-
-type SplashScreenHiddenContextType = {isSplashHidden: boolean};
-
-export default function useHybridAppMiddleware() {
- const {navigateToExitUrl, showSplashScreenOnNextStart} = useContext(HybridAppMiddlewareContext);
- return {navigateToExitUrl, showSplashScreenOnNextStart};
-}
-
-export type {SplashScreenHiddenContextType};
diff --git a/src/hooks/useTackInputFocus/index.ts b/src/hooks/useTackInputFocus/index.ts
index 124f8460127c..e6caa15f9dde 100644
--- a/src/hooks/useTackInputFocus/index.ts
+++ b/src/hooks/useTackInputFocus/index.ts
@@ -1,5 +1,6 @@
import {useCallback, useEffect} from 'react';
import useDebouncedState from '@hooks/useDebouncedState';
+import * as Browser from '@libs/Browser';
/**
* Detects input or text area focus on browsers, to avoid scrolling on virtual viewports
@@ -28,7 +29,13 @@ export default function useTackInputFocus(enable = false): boolean {
);
const resetScrollPositionOnVisualViewport = useCallback(() => {
- window.scrollTo({top: 0});
+ if (Browser.isChromeIOS() && window.visualViewport?.offsetTop) {
+ // On Chrome iOS, the visual viewport triggers a scroll event when the keyboard is opened, but some time the scroll position is not correct.
+ // So this change is specific to Chrome iOS, helping to reset the viewport position correctly.
+ window.scrollTo({top: -window.visualViewport.offsetTop});
+ } else {
+ window.scrollTo({top: 0});
+ }
}, []);
useEffect(() => {
diff --git a/src/hooks/useWindowDimensions/index.ts b/src/hooks/useWindowDimensions/index.ts
index 25757fda17e5..b391e45a61aa 100644
--- a/src/hooks/useWindowDimensions/index.ts
+++ b/src/hooks/useWindowDimensions/index.ts
@@ -23,7 +23,7 @@ export default function (useCachedViewportHeight = false): WindowDimensions {
unlockWindowDimensions: () => {},
};
- const isCachedViewportHeight = useCachedViewportHeight && Browser.isMobileSafari();
+ const isCachedViewportHeight = useCachedViewportHeight && Browser.isMobileWebKit();
const cachedViewportHeightWithKeyboardRef = useRef(initalViewportHeight);
const {width: windowWidth, height: windowHeight} = useWindowDimensions();
diff --git a/src/languages/en.ts b/src/languages/en.ts
index f789ddc5f840..c7b5125d02fa 100755
--- a/src/languages/en.ts
+++ b/src/languages/en.ts
@@ -171,6 +171,7 @@ export default {
wallet: 'Wallet',
preferences: 'Preferences',
view: 'View',
+ review: 'Review',
not: 'Not',
signIn: 'Sign in',
signInWithGoogle: 'Sign in with Google',
@@ -520,6 +521,7 @@ export default {
replyInThread: 'Reply in thread',
joinThread: 'Join thread',
leaveThread: 'Leave thread',
+ copyOnyxData: 'Copy Onyx data',
flagAsOffensive: 'Flag as offensive',
menu: 'Menu',
},
@@ -1462,6 +1464,7 @@ export default {
title: 'What do you want to do today?',
errorSelection: 'Please make a selection to continue.',
errorContinue: 'Please press continue to get set up.',
+ errorBackButton: 'Please finish the setup questions to start using the app.',
[CONST.ONBOARDING_CHOICES.EMPLOYER]: 'Get paid back by my employer',
[CONST.ONBOARDING_CHOICES.MANAGE_TEAM]: "Manage my team's expenses",
[CONST.ONBOARDING_CHOICES.PERSONAL_SPEND]: 'Track and budget expenses',
@@ -3055,6 +3058,8 @@ export default {
return 'Updating people list';
case 'quickbooksOnlineSyncApplyClassesLocations':
return 'Updating report fields';
+ case 'jobDone':
+ return 'Waiting for imported data to load';
case 'xeroSyncImportChartOfAccounts':
return 'Syncing chart of accounts';
case 'xeroSyncImportCategories':
@@ -3121,6 +3126,13 @@ export default {
defaultVendor: 'Default vendor',
autoSync: 'Auto-sync',
reimbursedReports: 'Sync reimbursed reports',
+ reconciliationAccount: 'Reconciliation account',
+ chooseReconciliationAccount: {
+ chooseBankAccount: 'Choose the bank account that your Expensify Card payments will be reconciled against.',
+ accountMatches: 'Make sure this account matches your ',
+ settlementAccount: 'Expensify Card settlement account ',
+ reconciliationWorks: (lastFourPAN: string) => `(ending in ${lastFourPAN}) so Continuous Reconciliation works properly.`,
+ },
},
bills: {
manageYourBills: 'Manage your bills',
@@ -4046,7 +4058,7 @@ export default {
},
paymentCard: {
addPaymentCard: 'Add payment card',
- enterPaymentCardDetails: 'Enter your payment card details.',
+ enterPaymentCardDetails: 'Enter your payment card details',
security: 'Expensify is PCI-DSS compliant, uses bank-level encryption, and utilizes redundant infrastructure to protect your data.',
learnMoreAboutSecurity: 'Learn more about our security.',
},
diff --git a/src/languages/es.ts b/src/languages/es.ts
index e9e23396f5d5..075903d0f324 100644
--- a/src/languages/es.ts
+++ b/src/languages/es.ts
@@ -161,6 +161,7 @@ export default {
wallet: 'Billetera',
preferences: 'Preferencias',
view: 'Ver',
+ review: 'Revisar',
not: 'No',
privacyPolicy: 'la Política de Privacidad de Expensify',
addCardTermsOfService: 'Términos de Servicio',
@@ -512,6 +513,7 @@ export default {
replyInThread: 'Responder en el hilo',
joinThread: 'Unirse al hilo',
leaveThread: 'Dejar hilo',
+ copyOnyxData: 'Copiar datos de Onyx',
flagAsOffensive: 'Marcar como ofensivo',
menu: 'Menú',
},
@@ -1470,6 +1472,7 @@ export default {
title: '¿Qué quieres hacer hoy?',
errorSelection: 'Por favor selecciona una opción para continuar.',
errorContinue: 'Por favor, haz click en continuar para configurar tu cuenta.',
+ errorBackButton: 'Por favor, finaliza las preguntas de configuración para empezar a utilizar la aplicación.',
[CONST.ONBOARDING_CHOICES.EMPLOYER]: 'Cobrar de mi empresa',
[CONST.ONBOARDING_CHOICES.MANAGE_TEAM]: 'Gestionar los gastos de mi equipo',
[CONST.ONBOARDING_CHOICES.PERSONAL_SPEND]: 'Controlar y presupuestar gastos',
@@ -3038,6 +3041,8 @@ export default {
return 'Actualizando empleados';
case 'quickbooksOnlineSyncApplyClassesLocations':
return 'Actualizando clases';
+ case 'jobDone':
+ return 'Esperando a que se carguen los datos importados';
case 'xeroSyncImportChartOfAccounts':
return 'Sincronizando plan de cuentas';
case 'xeroSyncImportCategories':
@@ -3093,6 +3098,7 @@ export default {
case 'intacctImportTitle':
return 'Importando datos desde Sage Intacct';
default: {
+ // eslint-disable-next-line @typescript-eslint/restrict-template-expressions
return `Translation missing for stage: ${stage}`;
}
}
@@ -3106,6 +3112,13 @@ export default {
defaultVendor: 'Proveedor predeterminado',
autoSync: 'Autosincronización',
reimbursedReports: 'Sincronizar informes reembolsados',
+ reconciliationAccount: 'Cuenta de conciliación',
+ chooseReconciliationAccount: {
+ chooseBankAccount: 'Elige la cuenta bancaria con la que se conciliarán los pagos de tu Tarjeta Expensify.',
+ accountMatches: 'Asegúrate de que esta cuenta coincide con ',
+ settlementAccount: 'la cuenta de liquidación de tu Tarjeta Expensify ',
+ reconciliationWorks: (lastFourPAN: string) => `(que termina en ${lastFourPAN}) para que la conciliación continua funcione correctamente.`,
+ },
},
card: {
header: 'Desbloquea Tarjetas Expensify gratis',
@@ -4560,7 +4573,7 @@ export default {
},
paymentCard: {
addPaymentCard: 'Añade tarjeta de pago',
- enterPaymentCardDetails: 'Introduce los datos de tu tarjeta de pago.',
+ enterPaymentCardDetails: 'Introduce los datos de tu tarjeta de pago',
security: 'Expensify es PCI-DSS obediente, utiliza cifrado a nivel bancario, y emplea infraestructura redundante para proteger tus datos.',
learnMoreAboutSecurity: 'Conozca más sobre nuestra seguridad.',
},
diff --git a/src/libs/API/parameters/PolicyReportFieldsReplace.ts b/src/libs/API/parameters/DeletePolicyReportField.ts
similarity index 66%
rename from src/libs/API/parameters/PolicyReportFieldsReplace.ts
rename to src/libs/API/parameters/DeletePolicyReportField.ts
index c6d1834f0789..d79e9b07249e 100644
--- a/src/libs/API/parameters/PolicyReportFieldsReplace.ts
+++ b/src/libs/API/parameters/DeletePolicyReportField.ts
@@ -1,4 +1,4 @@
-type PolicyReportFieldsReplace = {
+type DeletePolicyReportField = {
policyID: string;
/**
* Stringified JSON object with type of following structure:
@@ -7,4 +7,4 @@ type PolicyReportFieldsReplace = {
reportFields: string;
};
-export default PolicyReportFieldsReplace;
+export default DeletePolicyReportField;
diff --git a/src/libs/API/parameters/Search.ts b/src/libs/API/parameters/Search.ts
index 0a8345b0b7e0..60ea54419492 100644
--- a/src/libs/API/parameters/Search.ts
+++ b/src/libs/API/parameters/Search.ts
@@ -1,4 +1,4 @@
-import type {SortOrder} from '@libs/SearchUtils';
+import type {SortOrder} from '@components/Search/types';
type SearchParams = {
hash: number;
diff --git a/src/libs/API/parameters/UpdateFrequentlyUsedEmojisParams.ts b/src/libs/API/parameters/UpdateFrequentlyUsedEmojisParams.ts
deleted file mode 100644
index f790ada3aad9..000000000000
--- a/src/libs/API/parameters/UpdateFrequentlyUsedEmojisParams.ts
+++ /dev/null
@@ -1,3 +0,0 @@
-type UpdateFrequentlyUsedEmojisParams = {value: string};
-
-export default UpdateFrequentlyUsedEmojisParams;
diff --git a/src/libs/API/parameters/index.ts b/src/libs/API/parameters/index.ts
index ff8465cfeec7..ff62d9b69ea6 100644
--- a/src/libs/API/parameters/index.ts
+++ b/src/libs/API/parameters/index.ts
@@ -64,7 +64,6 @@ export type {default as UpdateAutomaticTimezoneParams} from './UpdateAutomaticTi
export type {default as UpdateChatPriorityModeParams} from './UpdateChatPriorityModeParams';
export type {default as UpdateDateOfBirthParams} from './UpdateDateOfBirthParams';
export type {default as UpdateDisplayNameParams} from './UpdateDisplayNameParams';
-export type {default as UpdateFrequentlyUsedEmojisParams} from './UpdateFrequentlyUsedEmojisParams';
export type {default as UpdateGroupChatNameParams} from './UpdateGroupChatNameParams';
export type {default as UpdateGroupChatMemberRolesParams} from './UpdateGroupChatMemberRolesParams';
export type {default as UpdateHomeAddressParams} from './UpdateHomeAddressParams';
@@ -242,7 +241,7 @@ export type {default as DeleteMoneyRequestOnSearchParams} from './DeleteMoneyReq
export type {default as HoldMoneyRequestOnSearchParams} from './HoldMoneyRequestOnSearchParams';
export type {default as UnholdMoneyRequestOnSearchParams} from './UnholdMoneyRequestOnSearchParams';
export type {default as UpdateNetSuiteSubsidiaryParams} from './UpdateNetSuiteSubsidiaryParams';
-export type {default as PolicyReportFieldsReplace} from './PolicyReportFieldsReplace';
+export type {default as DeletePolicyReportField} from './DeletePolicyReportField';
export type {default as ConnectPolicyToNetSuiteParams} from './ConnectPolicyToNetSuiteParams';
export type {default as CreateWorkspaceReportFieldParams} from './CreateWorkspaceReportFieldParams';
export type {default as UpdateWorkspaceReportFieldInitialValueParams} from './UpdateWorkspaceReportFieldInitialValueParams';
diff --git a/src/libs/API/types.ts b/src/libs/API/types.ts
index ca284321e3bb..948ed7f76373 100644
--- a/src/libs/API/types.ts
+++ b/src/libs/API/types.ts
@@ -57,7 +57,6 @@ const WRITE_COMMANDS = {
VALIDATE_LOGIN: 'ValidateLogin',
VALIDATE_SECONDARY_LOGIN: 'ValidateSecondaryLogin',
UPDATE_PREFERRED_EMOJI_SKIN_TONE: 'UpdatePreferredEmojiSkinTone',
- UPDATE_FREQUENTLY_USED_EMOJIS: 'UpdateFrequentlyUsedEmojis',
UPDATE_CHAT_PRIORITY_MODE: 'UpdateChatPriorityMode',
SET_CONTACT_METHOD_AS_DEFAULT: 'SetContactMethodAsDefault',
UPDATE_THEME: 'UpdateTheme',
@@ -351,7 +350,6 @@ type WriteCommandParameters = {
[WRITE_COMMANDS.VALIDATE_LOGIN]: Parameters.ValidateLoginParams;
[WRITE_COMMANDS.VALIDATE_SECONDARY_LOGIN]: Parameters.ValidateSecondaryLoginParams;
[WRITE_COMMANDS.UPDATE_PREFERRED_EMOJI_SKIN_TONE]: Parameters.UpdatePreferredEmojiSkinToneParams;
- [WRITE_COMMANDS.UPDATE_FREQUENTLY_USED_EMOJIS]: Parameters.UpdateFrequentlyUsedEmojisParams;
[WRITE_COMMANDS.UPDATE_CHAT_PRIORITY_MODE]: Parameters.UpdateChatPriorityModeParams;
[WRITE_COMMANDS.SET_CONTACT_METHOD_AS_DEFAULT]: Parameters.SetContactMethodAsDefaultParams;
[WRITE_COMMANDS.UPDATE_THEME]: Parameters.UpdateThemeParams;
@@ -426,7 +424,7 @@ type WriteCommandParameters = {
[WRITE_COMMANDS.RENAME_WORKSPACE_CATEGORY]: Parameters.RenameWorkspaceCategoriesParams;
[WRITE_COMMANDS.SET_WORKSPACE_REQUIRES_CATEGORY]: Parameters.SetWorkspaceRequiresCategoryParams;
[WRITE_COMMANDS.DELETE_WORKSPACE_CATEGORIES]: Parameters.DeleteWorkspaceCategoriesParams;
- [WRITE_COMMANDS.DELETE_POLICY_REPORT_FIELD]: Parameters.PolicyReportFieldsReplace;
+ [WRITE_COMMANDS.DELETE_POLICY_REPORT_FIELD]: Parameters.DeletePolicyReportField;
[WRITE_COMMANDS.SET_POLICY_REQUIRES_TAG]: Parameters.SetPolicyRequiresTag;
[WRITE_COMMANDS.SET_POLICY_TAGS_REQUIRED]: Parameters.SetPolicyTagsRequired;
[WRITE_COMMANDS.RENAME_POLICY_TAG_LIST]: Parameters.RenamePolicyTaglistParams;
diff --git a/src/libs/Browser/index.ts b/src/libs/Browser/index.ts
index 98ad449c3dd0..aeec4f4def4a 100644
--- a/src/libs/Browser/index.ts
+++ b/src/libs/Browser/index.ts
@@ -1,4 +1,4 @@
-import type {GetBrowser, IsMobile, IsMobileChrome, IsMobileSafari, IsMobileWebKit, IsSafari, OpenRouteInDesktopApp} from './types';
+import type {GetBrowser, IsChromeIOS, IsMobile, IsMobileChrome, IsMobileSafari, IsMobileWebKit, IsSafari, OpenRouteInDesktopApp} from './types';
const getBrowser: GetBrowser = () => '';
@@ -10,8 +10,10 @@ const isMobileChrome: IsMobileChrome = () => false;
const isMobileWebKit: IsMobileWebKit = () => false;
+const isChromeIOS: IsChromeIOS = () => false;
+
const isSafari: IsSafari = () => false;
const openRouteInDesktopApp: OpenRouteInDesktopApp = () => {};
-export {getBrowser, isMobile, isMobileSafari, isMobileWebKit, isSafari, isMobileChrome, openRouteInDesktopApp};
+export {getBrowser, isMobile, isMobileSafari, isMobileWebKit, isSafari, isMobileChrome, isChromeIOS, openRouteInDesktopApp};
diff --git a/src/libs/Browser/index.website.ts b/src/libs/Browser/index.website.ts
index a83fa1cac70e..b89190dc7f78 100644
--- a/src/libs/Browser/index.website.ts
+++ b/src/libs/Browser/index.website.ts
@@ -1,7 +1,7 @@
import CONFIG from '@src/CONFIG';
import CONST from '@src/CONST';
import ROUTES from '@src/ROUTES';
-import type {GetBrowser, IsMobile, IsMobileChrome, IsMobileSafari, IsMobileWebKit, IsSafari, OpenRouteInDesktopApp} from './types';
+import type {GetBrowser, IsChromeIOS, IsMobile, IsMobileChrome, IsMobileSafari, IsMobileWebKit, IsSafari, OpenRouteInDesktopApp} from './types';
/**
* Fetch browser name from UA string
@@ -66,6 +66,14 @@ const isMobileWebKit: IsMobileWebKit = () => {
return /iP(ad|od|hone)/i.test(userAgent) && /WebKit/i.test(userAgent);
};
+/**
+ * Checks if the requesting user agent is a Chrome browser on an iOS mobile device.
+ */
+const isChromeIOS: IsChromeIOS = () => {
+ const userAgent = navigator.userAgent;
+ return /iP(ad|od|hone)/i.test(userAgent) && /CriOS/i.test(userAgent);
+};
+
const isSafari: IsSafari = () => getBrowser() === 'safari' || isMobileSafari();
/**
@@ -109,4 +117,4 @@ const openRouteInDesktopApp: OpenRouteInDesktopApp = (shortLivedAuthToken = '',
}
};
-export {getBrowser, isMobile, isMobileSafari, isMobileWebKit, isSafari, isMobileChrome, openRouteInDesktopApp};
+export {getBrowser, isMobile, isMobileSafari, isMobileWebKit, isSafari, isMobileChrome, isChromeIOS, openRouteInDesktopApp};
diff --git a/src/libs/Browser/types.ts b/src/libs/Browser/types.ts
index 25f305953c87..cb242d3729aa 100644
--- a/src/libs/Browser/types.ts
+++ b/src/libs/Browser/types.ts
@@ -8,8 +8,10 @@ type IsMobileChrome = () => boolean;
type IsMobileWebKit = () => boolean;
+type IsChromeIOS = () => boolean;
+
type IsSafari = () => boolean;
type OpenRouteInDesktopApp = (shortLivedAuthToken?: string, email?: string) => void;
-export type {GetBrowser, IsMobile, IsMobileSafari, IsMobileChrome, IsMobileWebKit, IsSafari, OpenRouteInDesktopApp};
+export type {GetBrowser, IsMobile, IsMobileSafari, IsMobileChrome, IsMobileWebKit, IsSafari, IsChromeIOS, OpenRouteInDesktopApp};
diff --git a/src/libs/CurrencyUtils.ts b/src/libs/CurrencyUtils.ts
index 862b0ae5e928..c3b80797d750 100644
--- a/src/libs/CurrencyUtils.ts
+++ b/src/libs/CurrencyUtils.ts
@@ -125,9 +125,11 @@ function convertToDisplayString(amountInCents = 0, currency: string = CONST.CURR
style: 'currency',
currency: currencyWithFallback,
- // We are forcing the number of decimals because we override the default number of decimals in the backend for RSD
+ // We are forcing the number of decimals because we override the default number of decimals in the backend for some currencies
// See: https://github.com/Expensify/PHP-Libs/pull/834
- minimumFractionDigits: currency === 'RSD' ? getCurrencyDecimals(currency) : undefined,
+ minimumFractionDigits: getCurrencyDecimals(currency),
+ // For currencies that have decimal places > 2, floor to 2 instead as we don't support more than 2 decimal places.
+ maximumFractionDigits: 2,
});
}
@@ -175,9 +177,11 @@ function convertToDisplayStringWithoutCurrency(amountInCents: number, currency:
style: 'currency',
currency,
- // We are forcing the number of decimals because we override the default number of decimals in the backend for RSD
+ // We are forcing the number of decimals because we override the default number of decimals in the backend for some currencies
// See: https://github.com/Expensify/PHP-Libs/pull/834
- minimumFractionDigits: currency === 'RSD' ? getCurrencyDecimals(currency) : undefined,
+ minimumFractionDigits: getCurrencyDecimals(currency),
+ // For currencies that have decimal places > 2, floor to 2 instead as we don't support more than 2 decimal places.
+ maximumFractionDigits: 2,
})
.filter((x) => x.type !== 'currency')
.filter((x) => x.type !== 'literal' || x.value.trim().length !== 0)
diff --git a/src/libs/E2E/reactNativeLaunchingTest.ts b/src/libs/E2E/reactNativeLaunchingTest.ts
index f952998f0aad..f61bee8dae6a 100644
--- a/src/libs/E2E/reactNativeLaunchingTest.ts
+++ b/src/libs/E2E/reactNativeLaunchingTest.ts
@@ -36,6 +36,7 @@ const tests: Tests = {
[E2EConfig.TEST_NAMES.ChatOpening]: require('./tests/chatOpeningTest.e2e').default,
[E2EConfig.TEST_NAMES.ReportTyping]: require('./tests/reportTypingTest.e2e').default,
[E2EConfig.TEST_NAMES.Linking]: require('./tests/linkingTest.e2e').default,
+ [E2EConfig.TEST_NAMES.PreloadedLinking]: require('./tests/preloadedLinkingTest.e2e').default,
};
// Once we receive the TII measurement we know that the app is initialized and ready to be used:
diff --git a/src/libs/E2E/tests/preloadedLinkingTest.e2e.ts b/src/libs/E2E/tests/preloadedLinkingTest.e2e.ts
new file mode 100644
index 000000000000..a36200b1a702
--- /dev/null
+++ b/src/libs/E2E/tests/preloadedLinkingTest.e2e.ts
@@ -0,0 +1,82 @@
+import {DeviceEventEmitter} from 'react-native';
+import type {NativeConfig} from 'react-native-config';
+import Config from 'react-native-config';
+import Timing from '@libs/actions/Timing';
+import E2ELogin from '@libs/E2E/actions/e2eLogin';
+import waitForAppLoaded from '@libs/E2E/actions/waitForAppLoaded';
+import E2EClient from '@libs/E2E/client';
+import getConfigValueOrThrow from '@libs/E2E/utils/getConfigValueOrThrow';
+import getPromiseWithResolve from '@libs/E2E/utils/getPromiseWithResolve';
+import Navigation from '@libs/Navigation/Navigation';
+import Performance from '@libs/Performance';
+import CONST from '@src/CONST';
+import ROUTES from '@src/ROUTES';
+
+type ViewableItem = {
+ reportActionID?: string;
+};
+type ViewableItemResponse = Array<{item?: ViewableItem}>;
+
+const test = (config: NativeConfig) => {
+ console.debug('[E2E] Logging in for comment linking');
+
+ const reportID = getConfigValueOrThrow('reportID', config);
+ const linkedReportActionID = getConfigValueOrThrow('linkedReportActionID', config);
+
+ E2ELogin().then((neededLogin) => {
+ if (neededLogin) {
+ return waitForAppLoaded().then(() => E2EClient.submitTestDone());
+ }
+
+ const [appearMessagePromise, appearMessageResolve] = getPromiseWithResolve();
+ const [switchReportPromise, switchReportResolve] = getPromiseWithResolve();
+
+ Promise.all([appearMessagePromise, switchReportPromise])
+ .then(() => {
+ console.debug('[E2E] Test completed successfully, exiting…');
+ E2EClient.submitTestDone();
+ })
+ .catch((err) => {
+ console.debug('[E2E] Error while submitting test results:', err);
+ });
+
+ const subscription = DeviceEventEmitter.addListener('onViewableItemsChanged', (res: ViewableItemResponse) => {
+ console.debug('[E2E] Viewable items retrieved, verifying correct message…', res);
+ if (!!res && res?.[0]?.item?.reportActionID === linkedReportActionID) {
+ appearMessageResolve();
+ subscription.remove();
+ } else {
+ console.debug(`[E2E] Provided message id '${res?.[0]?.item?.reportActionID}' doesn't match to an expected '${linkedReportActionID}'. Waiting for a next one…`);
+ }
+ });
+
+ Performance.subscribeToMeasurements((entry) => {
+ if (entry.name === CONST.TIMING.SIDEBAR_LOADED) {
+ console.debug('[E2E] Sidebar loaded, navigating to a report…');
+ Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(reportID));
+ return;
+ }
+
+ if (entry.name === CONST.TIMING.REPORT_INITIAL_RENDER) {
+ console.debug('[E2E] Navigating to linked report action…');
+ Timing.start(CONST.TIMING.SWITCH_REPORT);
+ Performance.markStart(CONST.TIMING.SWITCH_REPORT);
+
+ Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(reportID, linkedReportActionID));
+ return;
+ }
+
+ if (entry.name === CONST.TIMING.CHAT_RENDER) {
+ E2EClient.submitTestResults({
+ branch: Config.E2E_BRANCH,
+ name: 'Comment linking',
+ metric: entry.duration,
+ });
+
+ switchReportResolve();
+ }
+ });
+ });
+};
+
+export default test;
diff --git a/src/libs/EmojiUtils.ts b/src/libs/EmojiUtils.ts
index 6fb5725addfc..007a892c048e 100644
--- a/src/libs/EmojiUtils.ts
+++ b/src/libs/EmojiUtils.ts
@@ -1,4 +1,3 @@
-import {getUnixTime} from 'date-fns';
import {Str} from 'expensify-common';
import memoize from 'lodash/memoize';
import Onyx from 'react-native-onyx';
@@ -235,37 +234,6 @@ function mergeEmojisWithFrequentlyUsedEmojis(emojis: PickerEmojis): EmojiPickerL
return addSpacesToEmojiCategories(mergedEmojis);
}
-/**
- * Get the updated frequently used emojis list by usage
- */
-function getFrequentlyUsedEmojis(newEmoji: Emoji | Emoji[]): FrequentlyUsedEmoji[] {
- let frequentEmojiList = [...frequentlyUsedEmojis];
-
- const maxFrequentEmojiCount = CONST.EMOJI_FREQUENT_ROW_COUNT * CONST.EMOJI_NUM_PER_ROW - 1;
-
- const currentTimestamp = getUnixTime(new Date());
- (Array.isArray(newEmoji) ? [...newEmoji] : [newEmoji]).forEach((emoji) => {
- let currentEmojiCount = 1;
- const emojiIndex = frequentEmojiList.findIndex((e) => e.code === emoji.code);
- if (emojiIndex >= 0) {
- currentEmojiCount = frequentEmojiList[emojiIndex].count + 1;
- frequentEmojiList.splice(emojiIndex, 1);
- }
-
- const updatedEmoji = {...Emojis.emojiCodeTableWithSkinTones[emoji.code], count: currentEmojiCount, lastUpdatedAt: currentTimestamp};
-
- // We want to make sure the current emoji is added to the list
- // Hence, we take one less than the current frequent used emojis
- frequentEmojiList = frequentEmojiList.slice(0, maxFrequentEmojiCount);
- frequentEmojiList.push(updatedEmoji);
-
- // Sort the list by count and lastUpdatedAt in descending order
- frequentEmojiList.sort((a, b) => b.count - a.count || b.lastUpdatedAt - a.lastUpdatedAt);
- });
-
- return frequentEmojiList;
-}
-
/**
* Given an emoji item object, return an emoji code based on its type.
*/
@@ -601,7 +569,6 @@ export {
getLocalizedEmojiName,
getHeaderEmojis,
mergeEmojisWithFrequentlyUsedEmojis,
- getFrequentlyUsedEmojis,
containsOnlyEmojis,
replaceEmojis,
suggestEmojis,
diff --git a/src/libs/Environment/Environment.ts b/src/libs/Environment/Environment.ts
index 1f5a391d3b13..c343788bed05 100644
--- a/src/libs/Environment/Environment.ts
+++ b/src/libs/Environment/Environment.ts
@@ -38,6 +38,13 @@ function isDevelopment(): boolean {
return (Config?.ENVIRONMENT ?? CONST.ENVIRONMENT.DEV) === CONST.ENVIRONMENT.DEV;
}
+/**
+ * Are we running the app in staging?
+ */
+function isStaging(): boolean {
+ return (Config?.ENVIRONMENT ?? CONST.ENVIRONMENT.DEV) === CONST.ENVIRONMENT.STAGING;
+}
+
/**
* Are we running the app in production?
*/
@@ -76,4 +83,4 @@ function getSpotnanaEnvironmentTMCID(): Promise {
return getEnvironment().then((environment) => SPOTNANA_ENVIRONMENT_TMC_ID[environment]);
}
-export {getEnvironment, isInternalTestBuild, isDevelopment, isProduction, getEnvironmentURL, getOldDotEnvironmentURL, getTravelDotEnvironmentURL, getSpotnanaEnvironmentTMCID};
+export {getEnvironment, isInternalTestBuild, isDevelopment, isStaging, isProduction, getEnvironmentURL, getOldDotEnvironmentURL, getTravelDotEnvironmentURL, getSpotnanaEnvironmentTMCID};
diff --git a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx
index adaa25543223..ce43c78a6fee 100644
--- a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx
+++ b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx
@@ -396,6 +396,7 @@ const SettingsModalStackNavigator = createModalStackNavigator require('../../../../pages/workspace/accounting/intacct/advanced/SageIntacctAdvancedPage').default,
[SCREENS.WORKSPACE.ACCOUNTING.SAGE_INTACCT_PAYMENT_ACCOUNT]: () =>
require('../../../../pages/workspace/accounting/intacct/advanced/SageIntacctPaymentAccountPage').default,
+ [SCREENS.WORKSPACE.ACCOUNTING.RECONCILIATION_ACCOUNT_SETTINGS]: () => require('../../../../pages/workspace/accounting/ReconciliationAccountSettingsPage').default,
[SCREENS.WORKSPACE.WORKFLOWS_AUTO_REPORTING_FREQUENCY]: () => require('../../../../pages/workspace/workflows/WorkspaceAutoReportingFrequencyPage').default,
[SCREENS.WORKSPACE.WORKFLOWS_AUTO_REPORTING_MONTHLY_OFFSET]: () => require('../../../../pages/workspace/workflows/WorkspaceAutoReportingMonthlyOffsetPage').default,
[SCREENS.WORKSPACE.TAX_EDIT]: () => require('../../../../pages/workspace/taxes/WorkspaceEditTaxPage').default,
@@ -408,13 +409,13 @@ const SettingsModalStackNavigator = createModalStackNavigator require('../../../../pages/settings/Subscription/PaymentCard/ChangeBillingCurrency').default,
[SCREENS.SETTINGS.SUBSCRIPTION.ADD_PAYMENT_CARD]: () => require('../../../../pages/settings/Subscription/PaymentCard').default,
[SCREENS.SETTINGS.ADD_PAYMENT_CARD_CHANGE_CURRENCY]: () => require('../../../../pages/settings/PaymentCard/ChangeCurrency').default,
- [SCREENS.WORKSPACE.REPORT_FIELDS_CREATE]: () => require('../../../../pages/workspace/reportFields/CreateReportFieldPage').default,
- [SCREENS.WORKSPACE.REPORT_FIELD_SETTINGS]: () => require('../../../../pages/workspace/reportFields/ReportFieldSettingsPage').default,
- [SCREENS.WORKSPACE.REPORT_FIELDS_LIST_VALUES]: () => require('../../../../pages/workspace/reportFields/ReportFieldListValuesPage').default,
- [SCREENS.WORKSPACE.REPORT_FIELDS_ADD_VALUE]: () => require('../../../../pages/workspace/reportFields/ReportFieldAddListValuePage').default,
- [SCREENS.WORKSPACE.REPORT_FIELDS_VALUE_SETTINGS]: () => require('../../../../pages/workspace/reportFields/ReportFieldValueSettingsPage').default,
- [SCREENS.WORKSPACE.REPORT_FIELDS_EDIT_INITIAL_VALUE]: () => require('../../../../pages/workspace/reportFields/ReportFieldInitialValuePage').default,
- [SCREENS.WORKSPACE.REPORT_FIELDS_EDIT_VALUE]: () => require('../../../../pages/workspace/reportFields/ReportFieldEditValuePage').default,
+ [SCREENS.WORKSPACE.REPORT_FIELDS_CREATE]: () => require('../../../../pages/workspace/reportFields/CreateReportFieldsPage').default,
+ [SCREENS.WORKSPACE.REPORT_FIELDS_SETTINGS]: () => require('../../../../pages/workspace/reportFields/ReportFieldsSettingsPage').default,
+ [SCREENS.WORKSPACE.REPORT_FIELDS_LIST_VALUES]: () => require('../../../../pages/workspace/reportFields/ReportFieldsListValuesPage').default,
+ [SCREENS.WORKSPACE.REPORT_FIELDS_ADD_VALUE]: () => require('../../../../pages/workspace/reportFields/ReportFieldsAddListValuePage').default,
+ [SCREENS.WORKSPACE.REPORT_FIELDS_VALUE_SETTINGS]: () => require('../../../../pages/workspace/reportFields/ReportFieldsValueSettingsPage').default,
+ [SCREENS.WORKSPACE.REPORT_FIELDS_EDIT_INITIAL_VALUE]: () => require('../../../../pages/workspace/reportFields/ReportFieldsInitialValuePage').default,
+ [SCREENS.WORKSPACE.REPORT_FIELDS_EDIT_VALUE]: () => require('../../../../pages/workspace/reportFields/ReportFieldsEditValuePage').default,
[SCREENS.WORKSPACE.ACCOUNTING.SAGE_INTACCT_IMPORT]: () => require('../../../../pages/workspace/accounting/intacct/import/SageIntacctImportPage').default,
[SCREENS.WORKSPACE.ACCOUNTING.SAGE_INTACCT_TOGGLE_MAPPING]: () =>
require('../../../../pages/workspace/accounting/intacct/import/SageIntacctToggleMappingsPage').default,
@@ -481,6 +482,7 @@ const TransactionDuplicateStackNavigator = createModalStackNavigator({
[SCREENS.SEARCH.REPORT_RHP]: () => require('../../../../pages/home/ReportScreen').default,
+ [SCREENS.SEARCH.TRANSACTION_HOLD_REASON_RHP]: () => require('../../../../pages/Search/SearchHoldReasonPage').default,
});
const RestrictedActionModalStackNavigator = createModalStackNavigator({
diff --git a/src/libs/Navigation/AppNavigator/Navigators/OnboardingModalNavigator.tsx b/src/libs/Navigation/AppNavigator/Navigators/OnboardingModalNavigator.tsx
index 29a2205b2e37..61adcd77da76 100644
--- a/src/libs/Navigation/AppNavigator/Navigators/OnboardingModalNavigator.tsx
+++ b/src/libs/Navigation/AppNavigator/Navigators/OnboardingModalNavigator.tsx
@@ -4,9 +4,11 @@ import {View} from 'react-native';
import {useOnyx} from 'react-native-onyx';
import NoDropZone from '@components/DragAndDrop/NoDropZone';
import FocusTrapForScreens from '@components/FocusTrap/FocusTrapForScreen';
+import useDisableModalDismissOnEscape from '@hooks/useDisableModalDismissOnEscape';
import useKeyboardShortcut from '@hooks/useKeyboardShortcut';
import useOnboardingLayout from '@hooks/useOnboardingLayout';
import useThemeStyles from '@hooks/useThemeStyles';
+import hasCompletedGuidedSetupFlowSelector from '@libs/hasCompletedGuidedSetupFlowSelector';
import OnboardingModalNavigatorScreenOptions from '@libs/Navigation/AppNavigator/OnboardingModalNavigatorScreenOptions';
import Navigation from '@libs/Navigation/Navigation';
import type {OnboardingModalNavigatorParamList} from '@libs/Navigation/types';
@@ -26,15 +28,11 @@ function OnboardingModalNavigator() {
const styles = useThemeStyles();
const {shouldUseNarrowLayout} = useOnboardingLayout();
const [hasCompletedGuidedSetupFlow] = useOnyx(ONYXKEYS.NVP_ONBOARDING, {
- selector: (onboarding) => {
- // onboarding is an array for old accounts and accounts created from olddot
- if (Array.isArray(onboarding)) {
- return true;
- }
- return onboarding?.hasCompletedGuidedSetupFlow;
- },
+ selector: hasCompletedGuidedSetupFlowSelector,
});
+ useDisableModalDismissOnEscape();
+
useEffect(() => {
if (!hasCompletedGuidedSetupFlow) {
return;
diff --git a/src/libs/Navigation/AppNavigator/createCustomBottomTabNavigator/BottomTabBar/index.website.tsx b/src/libs/Navigation/AppNavigator/createCustomBottomTabNavigator/BottomTabBar/index.website.tsx
index 8c531a918af8..2e1c4c012156 100644
--- a/src/libs/Navigation/AppNavigator/createCustomBottomTabNavigator/BottomTabBar/index.website.tsx
+++ b/src/libs/Navigation/AppNavigator/createCustomBottomTabNavigator/BottomTabBar/index.website.tsx
@@ -15,7 +15,9 @@ import * as Session from '@libs/actions/Session';
import interceptAnonymousUser from '@libs/interceptAnonymousUser';
import getTopmostBottomTabRoute from '@libs/Navigation/getTopmostBottomTabRoute';
import getTopmostCentralPaneRoute from '@libs/Navigation/getTopmostCentralPaneRoute';
-import Navigation from '@libs/Navigation/Navigation';
+import linkingConfig from '@libs/Navigation/linkingConfig';
+import getAdaptedStateFromPath from '@libs/Navigation/linkingConfig/getAdaptedStateFromPath';
+import Navigation, {navigationRef} from '@libs/Navigation/Navigation';
import type {RootStackParamList, State} from '@libs/Navigation/types';
import {isCentralPaneName} from '@libs/NavigationUtils';
import {getChatTabBrickRoad} from '@libs/WorkspacesSettingsUtils';
@@ -53,7 +55,12 @@ function BottomTabBar({isLoadingApp = false}: PurposeForUsingExpensifyModalProps
return;
}
- Welcome.isOnboardingFlowCompleted({onNotCompleted: () => Navigation.navigate(ROUTES.ONBOARDING_ROOT)});
+ Welcome.isOnboardingFlowCompleted({
+ onNotCompleted: () => {
+ const {adaptedState} = getAdaptedStateFromPath(ROUTES.ONBOARDING_ROOT, linkingConfig.config);
+ navigationRef.resetRoot(adaptedState);
+ },
+ });
// eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps
}, [isLoadingApp]);
diff --git a/src/libs/Navigation/AppNavigator/createCustomStackNavigator/CustomRouter.ts b/src/libs/Navigation/AppNavigator/createCustomStackNavigator/CustomRouter.ts
index a1768df5e0d6..5b3cefb63a2d 100644
--- a/src/libs/Navigation/AppNavigator/createCustomStackNavigator/CustomRouter.ts
+++ b/src/libs/Navigation/AppNavigator/createCustomStackNavigator/CustomRouter.ts
@@ -1,13 +1,16 @@
-import type {RouterConfigOptions, StackNavigationState} from '@react-navigation/native';
-import {getPathFromState, StackRouter} from '@react-navigation/native';
+import type {CommonActions, RouterConfigOptions, StackActionType, StackNavigationState} from '@react-navigation/native';
+import {findFocusedRoute, getPathFromState, StackRouter} from '@react-navigation/native';
import type {ParamListBase} from '@react-navigation/routers';
import getIsNarrowLayout from '@libs/getIsNarrowLayout';
+import * as Localize from '@libs/Localize';
import getTopmostBottomTabRoute from '@libs/Navigation/getTopmostBottomTabRoute';
import getTopmostCentralPaneRoute from '@libs/Navigation/getTopmostCentralPaneRoute';
import linkingConfig from '@libs/Navigation/linkingConfig';
import getAdaptedStateFromPath from '@libs/Navigation/linkingConfig/getAdaptedStateFromPath';
import type {NavigationPartialRoute, RootStackParamList, State} from '@libs/Navigation/types';
-import {isCentralPaneName} from '@libs/NavigationUtils';
+import {isCentralPaneName, isOnboardingFlowName} from '@libs/NavigationUtils';
+import * as Welcome from '@userActions/Welcome';
+import CONST from '@src/CONST';
import NAVIGATORS from '@src/NAVIGATORS';
import SCREENS from '@src/SCREENS';
import type {ResponsiveStackNavigatorRouterOptions} from './types';
@@ -97,6 +100,23 @@ function compareAndAdaptState(state: StackNavigationState) {
}
}
+function shouldPreventReset(state: StackNavigationState, action: CommonActions.Action | StackActionType) {
+ if (action.type !== CONST.NAVIGATION_ACTIONS.RESET || !action?.payload) {
+ return false;
+ }
+ const currentFocusedRoute = findFocusedRoute(state);
+ const targetFocusedRoute = findFocusedRoute(action?.payload);
+
+ // We want to prevent the user from navigating back to a non-onboarding screen if they are currently on an onboarding screen
+ if (isOnboardingFlowName(currentFocusedRoute?.name) && !isOnboardingFlowName(targetFocusedRoute?.name)) {
+ Welcome.setOnboardingErrorMessage(Localize.translateLocal('onboarding.purpose.errorBackButton'));
+ // We reset the URL as the browser sets it in a way that doesn't match the navigation state
+ // eslint-disable-next-line no-restricted-globals
+ history.replaceState({}, '', getPathFromState(state, linkingConfig.config));
+ return true;
+ }
+}
+
function CustomRouter(options: ResponsiveStackNavigatorRouterOptions) {
const stackRouter = StackRouter(options);
@@ -107,6 +127,12 @@ function CustomRouter(options: ResponsiveStackNavigatorRouterOptions) {
const state = stackRouter.getRehydratedState(partialState, {routeNames, routeParamList, routeGetIdList});
return state;
},
+ getStateForAction(state: StackNavigationState, action: CommonActions.Action | StackActionType, configOptions: RouterConfigOptions) {
+ if (shouldPreventReset(state, action)) {
+ return state;
+ }
+ return stackRouter.getStateForAction(state, action, configOptions);
+ },
};
}
diff --git a/src/libs/Navigation/Navigation.ts b/src/libs/Navigation/Navigation.ts
index 15d4ac6e4b31..6f803ae1e497 100644
--- a/src/libs/Navigation/Navigation.ts
+++ b/src/libs/Navigation/Navigation.ts
@@ -16,10 +16,12 @@ import type {Report} from '@src/types/onyx';
import originalCloseRHPFlow from './closeRHPFlow';
import originalDismissModal from './dismissModal';
import originalDismissModalWithReport from './dismissModalWithReport';
+import getTopmostBottomTabRoute from './getTopmostBottomTabRoute';
import getTopmostCentralPaneRoute from './getTopmostCentralPaneRoute';
import originalGetTopmostReportActionId from './getTopmostReportActionID';
import originalGetTopmostReportId from './getTopmostReportId';
import linkingConfig from './linkingConfig';
+import getMatchingBottomTabRouteForState from './linkingConfig/getMatchingBottomTabRouteForState';
import linkTo from './linkTo';
import navigationRef from './navigationRef';
import setNavigationActionToMicrotaskQueue from './setNavigationActionToMicrotaskQueue';
@@ -38,8 +40,8 @@ let shouldPopAllStateOnUP = false;
/**
* Inform the navigation that next time user presses UP we should pop all the state back to LHN.
*/
-function setShouldPopAllStateOnUP() {
- shouldPopAllStateOnUP = true;
+function setShouldPopAllStateOnUP(shouldPopAllStateFlag: boolean) {
+ shouldPopAllStateOnUP = shouldPopAllStateFlag;
}
function canNavigate(methodName: string, params: Record = {}): boolean {
@@ -229,16 +231,40 @@ function goBack(fallbackRoute?: Route, shouldEnforceFallback = false, shouldPopT
const isCentralPaneFocused = isCentralPaneName(findFocusedRoute(navigationRef.current.getState())?.name);
const distanceFromPathInRootNavigator = getDistanceFromPathInRootNavigator(fallbackRoute ?? '');
- // Allow CentralPane to use UP with fallback route if the path is not found in root navigator.
- if (isCentralPaneFocused && fallbackRoute && distanceFromPathInRootNavigator === -1) {
- navigate(fallbackRoute, CONST.NAVIGATION.TYPE.UP);
- return;
+ if (isCentralPaneFocused && fallbackRoute) {
+ // Allow CentralPane to use UP with fallback route if the path is not found in root navigator.
+ if (distanceFromPathInRootNavigator === -1) {
+ navigate(fallbackRoute, CONST.NAVIGATION.TYPE.UP);
+ return;
+ }
+
+ // Add possibility to go back more than one screen in root navigator if that screen is on the stack.
+ if (distanceFromPathInRootNavigator > 0) {
+ navigationRef.current.dispatch(StackActions.pop(distanceFromPathInRootNavigator));
+ return;
+ }
}
- // Add possibility to go back more than one screen in root navigator if that screen is on the stack.
- if (isCentralPaneFocused && fallbackRoute && distanceFromPathInRootNavigator > 0) {
- navigationRef.current.dispatch(StackActions.pop(distanceFromPathInRootNavigator));
- return;
+ // If the central pane is focused, it's possible that we navigated from other central pane with different matching bottom tab.
+ if (isCentralPaneFocused) {
+ const rootState = navigationRef.getRootState();
+ const stateAfterPop = {routes: rootState.routes.slice(0, -1)} as State;
+ const topmostCentralPaneRouteAfterPop = getTopmostCentralPaneRoute(stateAfterPop);
+
+ const topmostBottomTabRoute = getTopmostBottomTabRoute(rootState as State);
+ const matchingBottomTabRoute = getMatchingBottomTabRouteForState(stateAfterPop);
+
+ // If the central pane is defined after the pop action, we need to check if it's synced with the bottom tab screen.
+ // If not, we need to pop to the bottom tab screen/screens to sync it with the new central pane.
+ if (topmostCentralPaneRouteAfterPop && topmostBottomTabRoute?.name !== matchingBottomTabRoute.name) {
+ const bottomTabNavigator = rootState.routes.find((item: NavigationStateRoute) => item.name === NAVIGATORS.BOTTOM_TAB_NAVIGATOR)?.state;
+
+ if (bottomTabNavigator && bottomTabNavigator.index) {
+ const matchingIndex = bottomTabNavigator.routes.findLastIndex((item) => item.name === matchingBottomTabRoute.name);
+ const indexToPop = matchingIndex !== -1 ? bottomTabNavigator.index - matchingIndex : undefined;
+ navigationRef.current.dispatch({...StackActions.pop(indexToPop), target: bottomTabNavigator?.key});
+ }
+ }
}
navigationRef.current.goBack();
diff --git a/src/libs/Navigation/NavigationRoot.tsx b/src/libs/Navigation/NavigationRoot.tsx
index db64aea7ffe8..a225831b56ff 100644
--- a/src/libs/Navigation/NavigationRoot.tsx
+++ b/src/libs/Navigation/NavigationRoot.tsx
@@ -1,6 +1,7 @@
import type {NavigationState} from '@react-navigation/native';
import {DefaultTheme, findFocusedRoute, NavigationContainer} from '@react-navigation/native';
import React, {useContext, useEffect, useMemo, useRef} from 'react';
+import {useOnyx} from 'react-native-onyx';
import HybridAppMiddleware from '@components/HybridAppMiddleware';
import {ScrollOffsetContext} from '@components/ScrollOffsetContextProvider';
import useActiveWorkspace from '@hooks/useActiveWorkspace';
@@ -8,11 +9,14 @@ import useCurrentReportID from '@hooks/useCurrentReportID';
import useTheme from '@hooks/useTheme';
import useWindowDimensions from '@hooks/useWindowDimensions';
import {FSPage} from '@libs/Fullstory';
+import hasCompletedGuidedSetupFlowSelector from '@libs/hasCompletedGuidedSetupFlowSelector';
import Log from '@libs/Log';
import {getPathFromURL} from '@libs/Url';
import {updateLastVisitedPath} from '@userActions/App';
import CONST from '@src/CONST';
+import ONYXKEYS from '@src/ONYXKEYS';
import type {Route} from '@src/ROUTES';
+import ROUTES from '@src/ROUTES';
import AppNavigator from './AppNavigator';
import getPolicyIDFromState from './getPolicyIDFromState';
import linkingConfig from './linkingConfig';
@@ -77,25 +81,37 @@ function NavigationRoot({authenticated, lastVisitedPath, initialUrl, onReady}: N
const {isSmallScreenWidth} = useWindowDimensions();
const {setActiveWorkspaceID} = useActiveWorkspace();
- const initialState = useMemo(
- () => {
- if (!lastVisitedPath) {
- return undefined;
- }
+ const [hasCompletedGuidedSetupFlow] = useOnyx(ONYXKEYS.NVP_ONBOARDING, {
+ selector: hasCompletedGuidedSetupFlowSelector,
+ });
- const path = initialUrl ? getPathFromURL(initialUrl) : null;
+ const initialState = useMemo(() => {
+ // If the user haven't completed the flow, we want to always redirect them to the onboarding flow.
+ if (!hasCompletedGuidedSetupFlow) {
+ const {adaptedState} = getAdaptedStateFromPath(ROUTES.ONBOARDING_ROOT, linkingConfig.config);
+ return adaptedState;
+ }
- // For non-nullable paths we don't want to set initial state
- if (path) {
- return;
- }
+ // If there is no lastVisitedPath, we can do early return. We won't modify the default behavior.
+ if (!lastVisitedPath) {
+ return undefined;
+ }
- const {adaptedState} = getAdaptedStateFromPath(lastVisitedPath, linkingConfig.config);
- return adaptedState;
- },
+ const path = initialUrl ? getPathFromURL(initialUrl) : null;
+
+ // If the user opens the root of app "/" it will be parsed to empty string "".
+ // If the path is defined and different that empty string we don't want to modify the default behavior.
+ if (path) {
+ return;
+ }
+
+ // Otherwise we want to redirect the user to the last visited path.
+ const {adaptedState} = getAdaptedStateFromPath(lastVisitedPath, linkingConfig.config);
+ return adaptedState;
+
+ // The initialState value is relevant only on the first render.
// eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps
- [],
- );
+ }, []);
// https://reactnavigation.org/docs/themes
const navigationTheme = useMemo(
@@ -119,10 +135,8 @@ function NavigationRoot({authenticated, lastVisitedPath, initialUrl, onReady}: N
firstRenderRef.current = false;
return;
}
- if (!isSmallScreenWidth) {
- return;
- }
- Navigation.setShouldPopAllStateOnUP();
+
+ Navigation.setShouldPopAllStateOnUP(!isSmallScreenWidth);
}, [isSmallScreenWidth]);
const handleStateChange = (state: NavigationState | undefined) => {
@@ -154,7 +168,7 @@ function NavigationRoot({authenticated, lastVisitedPath, initialUrl, onReady}: N
}}
>
{/* HybridAppMiddleware needs to have access to navigation ref and SplashScreenHidden context */}
-
+
diff --git a/src/libs/Navigation/isReportOpenInRHP.ts b/src/libs/Navigation/isReportOpenInRHP.ts
new file mode 100644
index 000000000000..51e8a95bb66b
--- /dev/null
+++ b/src/libs/Navigation/isReportOpenInRHP.ts
@@ -0,0 +1,17 @@
+import type {NavigationState} from '@react-navigation/native';
+import NAVIGATORS from '@src/NAVIGATORS';
+import SCREENS from '@src/SCREENS';
+
+const isReportOpenInRHP = (state: NavigationState | undefined): boolean => {
+ const lastRoute = state?.routes?.at(-1);
+ if (!lastRoute) {
+ return false;
+ }
+ const params = lastRoute.params;
+ if (params && 'screen' in params && typeof params.screen === 'string' && params.screen === SCREENS.RIGHT_MODAL.SEARCH_REPORT) {
+ return true;
+ }
+ return !!(lastRoute.name === NAVIGATORS.RIGHT_MODAL_NAVIGATOR && lastRoute.state?.routes?.some((route) => route?.name === SCREENS.RIGHT_MODAL.SEARCH_REPORT));
+};
+
+export default isReportOpenInRHP;
diff --git a/src/libs/Navigation/linkTo/index.ts b/src/libs/Navigation/linkTo/index.ts
index d8312937ed6f..ddef2b1411b3 100644
--- a/src/libs/Navigation/linkTo/index.ts
+++ b/src/libs/Navigation/linkTo/index.ts
@@ -3,6 +3,7 @@ import type {NavigationContainerRef, NavigationState, PartialState} from '@react
import {findFocusedRoute} from '@react-navigation/native';
import {omitBy} from 'lodash';
import getIsNarrowLayout from '@libs/getIsNarrowLayout';
+import isReportOpenInRHP from '@libs/Navigation/isReportOpenInRHP';
import extractPolicyIDsFromState from '@libs/Navigation/linkingConfig/extractPolicyIDsFromState';
import {isCentralPaneName} from '@libs/NavigationUtils';
import shallowCompare from '@libs/ObjectUtils';
@@ -68,7 +69,7 @@ export default function linkTo(navigation: NavigationContainerRef route?.name === SCREENS.RIGHT_MODAL.SEARCH_REPORT);
+ const isReportInRhpOpened = isReportOpenInRHP(rootState);
// If action type is different than NAVIGATE we can't change it to the PUSH safely
if (action?.type === CONST.NAVIGATION.ACTION_TYPE.NAVIGATE) {
diff --git a/src/libs/Navigation/linkingConfig/CENTRAL_PANE_TO_RHP_MAPPING.ts b/src/libs/Navigation/linkingConfig/CENTRAL_PANE_TO_RHP_MAPPING.ts
index 1192e4649ea0..83929b7e7d02 100755
--- a/src/libs/Navigation/linkingConfig/CENTRAL_PANE_TO_RHP_MAPPING.ts
+++ b/src/libs/Navigation/linkingConfig/CENTRAL_PANE_TO_RHP_MAPPING.ts
@@ -38,7 +38,7 @@ const CENTRAL_PANE_TO_RHP_MAPPING: Partial> =
[SCREENS.SETTINGS.ABOUT]: [SCREENS.SETTINGS.APP_DOWNLOAD_LINKS],
[SCREENS.SETTINGS.SAVE_THE_WORLD]: [SCREENS.I_KNOW_A_TEACHER, SCREENS.INTRO_SCHOOL_PRINCIPAL, SCREENS.I_AM_A_TEACHER],
[SCREENS.SETTINGS.TROUBLESHOOT]: [SCREENS.SETTINGS.CONSOLE],
- [SCREENS.SEARCH.CENTRAL_PANE]: [SCREENS.SEARCH.REPORT_RHP],
+ [SCREENS.SEARCH.CENTRAL_PANE]: [SCREENS.SEARCH.REPORT_RHP, SCREENS.SEARCH.TRANSACTION_HOLD_REASON_RHP],
[SCREENS.SETTINGS.SUBSCRIPTION.ROOT]: [
SCREENS.SETTINGS.SUBSCRIPTION.ADD_PAYMENT_CARD,
SCREENS.SETTINGS.SUBSCRIPTION.SIZE,
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 54804a495754..31b44a2681fd 100755
--- a/src/libs/Navigation/linkingConfig/FULL_SCREEN_TO_RHP_MAPPING.ts
+++ b/src/libs/Navigation/linkingConfig/FULL_SCREEN_TO_RHP_MAPPING.ts
@@ -112,6 +112,7 @@ const FULL_SCREEN_TO_RHP_MAPPING: Partial> = {
SCREENS.WORKSPACE.ACCOUNTING.SAGE_INTACCT_NON_REIMBURSABLE_CREDIT_CARD_ACCOUNT,
SCREENS.WORKSPACE.ACCOUNTING.SAGE_INTACCT_ADVANCED,
SCREENS.WORKSPACE.ACCOUNTING.SAGE_INTACCT_PAYMENT_ACCOUNT,
+ SCREENS.WORKSPACE.ACCOUNTING.RECONCILIATION_ACCOUNT_SETTINGS,
],
[SCREENS.WORKSPACE.TAXES]: [
SCREENS.WORKSPACE.TAXES_SETTINGS,
@@ -143,7 +144,7 @@ const FULL_SCREEN_TO_RHP_MAPPING: Partial> = {
],
[SCREENS.WORKSPACE.REPORT_FIELDS]: [
SCREENS.WORKSPACE.REPORT_FIELDS_CREATE,
- SCREENS.WORKSPACE.REPORT_FIELD_SETTINGS,
+ SCREENS.WORKSPACE.REPORT_FIELDS_SETTINGS,
SCREENS.WORKSPACE.REPORT_FIELDS_LIST_VALUES,
SCREENS.WORKSPACE.REPORT_FIELDS_ADD_VALUE,
SCREENS.WORKSPACE.REPORT_FIELDS_VALUE_SETTINGS,
diff --git a/src/libs/Navigation/linkingConfig/config.ts b/src/libs/Navigation/linkingConfig/config.ts
index bdccc65ad2a0..5262850d4e81 100644
--- a/src/libs/Navigation/linkingConfig/config.ts
+++ b/src/libs/Navigation/linkingConfig/config.ts
@@ -447,6 +447,7 @@ const config: LinkingOptions['config'] = {
},
[SCREENS.WORKSPACE.ACCOUNTING.SAGE_INTACCT_ADVANCED]: {path: ROUTES.POLICY_ACCOUNTING_SAGE_INTACCT_ADVANCED.route},
[SCREENS.WORKSPACE.ACCOUNTING.SAGE_INTACCT_PAYMENT_ACCOUNT]: {path: ROUTES.POLICY_ACCOUNTING_SAGE_INTACCT_PAYMENT_ACCOUNT.route},
+ [SCREENS.WORKSPACE.ACCOUNTING.RECONCILIATION_ACCOUNT_SETTINGS]: {path: ROUTES.WORKSPACE_ACCOUNTING_RECONCILIATION_ACCOUNT_SETTINGS.route},
[SCREENS.WORKSPACE.DESCRIPTION]: {
path: ROUTES.WORKSPACE_PROFILE_DESCRIPTION.route,
},
@@ -585,34 +586,34 @@ const config: LinkingOptions['config'] = {
path: ROUTES.WORKSPACE_CREATE_REPORT_FIELD.route,
},
[SCREENS.WORKSPACE.REPORT_FIELDS_LIST_VALUES]: {
- path: ROUTES.WORKSPACE_REPORT_FIELD_LIST_VALUES.route,
+ path: ROUTES.WORKSPACE_REPORT_FIELDS_LIST_VALUES.route,
parse: {
reportFieldID: (reportFieldID: string) => decodeURIComponent(reportFieldID),
},
},
[SCREENS.WORKSPACE.REPORT_FIELDS_ADD_VALUE]: {
- path: ROUTES.WORKSPACE_REPORT_FIELD_ADD_VALUE.route,
+ path: ROUTES.WORKSPACE_REPORT_FIELDS_ADD_VALUE.route,
parse: {
reportFieldID: (reportFieldID: string) => decodeURIComponent(reportFieldID),
},
},
[SCREENS.WORKSPACE.REPORT_FIELDS_VALUE_SETTINGS]: {
- path: ROUTES.WORKSPACE_REPORT_FIELD_VALUE_SETTINGS.route,
+ path: ROUTES.WORKSPACE_REPORT_FIELDS_VALUE_SETTINGS.route,
parse: {
reportFieldID: (reportFieldID: string) => decodeURIComponent(reportFieldID),
},
},
[SCREENS.WORKSPACE.REPORT_FIELDS_EDIT_VALUE]: {
- path: ROUTES.WORKSPACE_REPORT_FIELD_EDIT_VALUE.route,
+ path: ROUTES.WORKSPACE_REPORT_FIELDS_EDIT_VALUE.route,
},
- [SCREENS.WORKSPACE.REPORT_FIELD_SETTINGS]: {
- path: ROUTES.WORKSPACE_REPORT_FIELD_SETTINGS.route,
+ [SCREENS.WORKSPACE.REPORT_FIELDS_SETTINGS]: {
+ path: ROUTES.WORKSPACE_REPORT_FIELDS_SETTINGS.route,
parse: {
reportFieldID: (reportFieldID: string) => decodeURIComponent(reportFieldID),
},
},
[SCREENS.WORKSPACE.REPORT_FIELDS_EDIT_INITIAL_VALUE]: {
- path: ROUTES.WORKSPACE_EDIT_REPORT_FIELD_INITIAL_VALUE.route,
+ path: ROUTES.WORKSPACE_EDIT_REPORT_FIELDS_INITIAL_VALUE.route,
parse: {
reportFieldID: (reportFieldID: string) => decodeURIComponent(reportFieldID),
},
@@ -929,6 +930,7 @@ const config: LinkingOptions['config'] = {
[SCREENS.RIGHT_MODAL.SEARCH_REPORT]: {
screens: {
[SCREENS.SEARCH.REPORT_RHP]: ROUTES.SEARCH_REPORT.route,
+ [SCREENS.SEARCH.TRANSACTION_HOLD_REASON_RHP]: ROUTES.TRANSACTION_HOLD_REASON_RHP.route,
},
},
[SCREENS.RIGHT_MODAL.RESTRICTED_ACTION]: {
diff --git a/src/libs/Navigation/types.ts b/src/libs/Navigation/types.ts
index 7e14a59f477b..00fd98dc51aa 100644
--- a/src/libs/Navigation/types.ts
+++ b/src/libs/Navigation/types.ts
@@ -11,8 +11,8 @@ import type {
Route,
} from '@react-navigation/native';
import type {TupleToUnion, ValueOf} from 'type-fest';
+import type {SearchColumnType, SortOrder} from '@components/Search/types';
import type {IOURequestType} from '@libs/actions/IOU';
-import type {SearchColumnType, SortOrder} from '@libs/SearchUtils';
import type CONST from '@src/CONST';
import type {Country, IOUAction, IOUType} from '@src/CONST';
import type NAVIGATORS from '@src/NAVIGATORS';
@@ -294,7 +294,7 @@ type SettingsNavigatorParamList = {
policyID: string;
valueIndex: number;
};
- [SCREENS.WORKSPACE.REPORT_FIELD_SETTINGS]: {
+ [SCREENS.WORKSPACE.REPORT_FIELDS_SETTINGS]: {
policyID: string;
reportFieldID: string;
};
@@ -588,6 +588,10 @@ type SettingsNavigatorParamList = {
[SCREENS.WORKSPACE.ACCOUNTING.SAGE_INTACCT_PAYMENT_ACCOUNT]: {
policyID: string;
};
+ [SCREENS.WORKSPACE.ACCOUNTING.RECONCILIATION_ACCOUNT_SETTINGS]: {
+ policyID: string;
+ connection: ValueOf;
+ };
[SCREENS.GET_ASSISTANCE]: {
backTo: Routes;
};
@@ -1208,6 +1212,8 @@ type FullScreenName = keyof FullScreenNavigatorParamList;
type CentralPaneName = keyof CentralPaneScreensParamList;
+type OnboardingFlowName = keyof OnboardingModalNavigatorParamList;
+
type SwitchPolicyIDParams = {
policyID?: string;
route?: Routes;
@@ -1238,6 +1244,7 @@ export type {
NewChatNavigatorParamList,
NewTaskNavigatorParamList,
OnboardingModalNavigatorParamList,
+ OnboardingFlowName,
ParticipantsNavigatorParamList,
PrivateNotesNavigatorParamList,
ProfileNavigatorParamList,
diff --git a/src/libs/NavigationUtils.ts b/src/libs/NavigationUtils.ts
index 34fc0b971ef6..aa26268977a2 100644
--- a/src/libs/NavigationUtils.ts
+++ b/src/libs/NavigationUtils.ts
@@ -1,7 +1,7 @@
import cloneDeep from 'lodash/cloneDeep';
import SCREENS from '@src/SCREENS';
import getTopmostBottomTabRoute from './Navigation/getTopmostBottomTabRoute';
-import type {CentralPaneName, RootStackParamList, State} from './Navigation/types';
+import type {CentralPaneName, OnboardingFlowName, RootStackParamList, State} from './Navigation/types';
const CENTRAL_PANE_SCREEN_NAMES = new Set([
SCREENS.SETTINGS.WORKSPACES,
@@ -17,6 +17,8 @@ const CENTRAL_PANE_SCREEN_NAMES = new Set([
SCREENS.REPORT,
]);
+const ONBOARDING_SCREEN_NAMES = new Set([SCREENS.ONBOARDING.PERSONAL_DETAILS, SCREENS.ONBOARDING.PURPOSE, SCREENS.ONBOARDING.WORK, SCREENS.ONBOARDING_MODAL.ONBOARDING]);
+
function isCentralPaneName(screen: string | undefined): screen is CentralPaneName {
if (!screen) {
return false;
@@ -25,6 +27,14 @@ function isCentralPaneName(screen: string | undefined): screen is CentralPaneNam
return CENTRAL_PANE_SCREEN_NAMES.has(screen as CentralPaneName);
}
+function isOnboardingFlowName(screen: string | undefined): screen is OnboardingFlowName {
+ if (!screen) {
+ return false;
+ }
+
+ return ONBOARDING_SCREEN_NAMES.has(screen as OnboardingFlowName);
+}
+
const removePolicyIDParamFromState = (state: State) => {
const stateCopy = cloneDeep(state);
const bottomTabRoute = getTopmostBottomTabRoute(stateCopy);
@@ -34,4 +44,4 @@ const removePolicyIDParamFromState = (state: State) => {
return stateCopy;
};
-export {isCentralPaneName, removePolicyIDParamFromState};
+export {isCentralPaneName, removePolicyIDParamFromState, isOnboardingFlowName};
diff --git a/src/libs/OptionsListUtils.ts b/src/libs/OptionsListUtils.ts
index 330d9d6ef61d..73b04742878a 100644
--- a/src/libs/OptionsListUtils.ts
+++ b/src/libs/OptionsListUtils.ts
@@ -657,10 +657,11 @@ function isSearchStringMatchUserDetails(personalDetail: PersonalDetails, searchV
* Get the last message text from the report directly or from other sources for special cases.
*/
function getLastMessageTextForReport(report: OnyxEntry, lastActorDetails: Partial | null, policy?: OnyxEntry): string {
- const lastReportAction = visibleReportActionItems[report?.reportID ?? '-1'] ?? null;
+ const reportID = report?.reportID ?? '-1';
+ const lastReportAction = visibleReportActionItems[reportID] ?? null;
// some types of actions are filtered out for lastReportAction, in some cases we need to check the actual last action
- const lastOriginalReportAction = lastReportActions[report?.reportID ?? '-1'] ?? null;
+ const lastOriginalReportAction = lastReportActions[reportID] ?? null;
let lastMessageTextFromReport = '';
if (ReportUtils.isArchivedRoom(report)) {
@@ -720,8 +721,10 @@ function getLastMessageTextForReport(report: OnyxEntry, lastActorDetails
lastMessageTextFromReport = ReportUtils.formatReportLastMessageText(TaskUtils.getTaskReportActionMessage(lastReportAction).text);
} else if (ReportActionUtils.isCreatedTaskReportAction(lastReportAction)) {
lastMessageTextFromReport = TaskUtils.getTaskCreatedMessage(lastReportAction);
- } else if (ReportActionUtils.isApprovedOrSubmittedReportAction(lastReportAction)) {
- lastMessageTextFromReport = ReportActionUtils.getReportActionMessageText(lastReportAction);
+ } else if (lastReportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.SUBMITTED) {
+ lastMessageTextFromReport = ReportUtils.getIOUSubmittedMessage(reportID);
+ } else if (lastReportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.APPROVED) {
+ lastMessageTextFromReport = ReportUtils.getIOUApprovedMessage(reportID);
}
return lastMessageTextFromReport || (report?.lastMessageText ?? '');
diff --git a/src/libs/ReportActionComposeFocusManager.ts b/src/libs/ReportActionComposeFocusManager.ts
index 0765f5f0ba75..2dfa81d89a20 100644
--- a/src/libs/ReportActionComposeFocusManager.ts
+++ b/src/libs/ReportActionComposeFocusManager.ts
@@ -1,8 +1,10 @@
import React from 'react';
import type {MutableRefObject} from 'react';
import type {TextInput} from 'react-native';
-import ROUTES from '@src/ROUTES';
-import Navigation from './Navigation/Navigation';
+import SCREENS from '@src/SCREENS';
+import getTopmostRouteName from './Navigation/getTopmostRouteName';
+import isReportOpenInRHP from './Navigation/isReportOpenInRHP';
+import navigationRef from './Navigation/navigationRef';
type FocusCallback = (shouldFocusForNonBlurInputOnTapOutside?: boolean) => void;
@@ -31,8 +33,9 @@ function onComposerFocus(callback: FocusCallback | null, isMainComposer = false)
* Request focus on the ReportActionComposer
*/
function focus(shouldFocusForNonBlurInputOnTapOutside?: boolean) {
- /** Do not trigger the refocusing when the active route is not the report route, */
- if (!Navigation.isActiveRoute(ROUTES.REPORT_WITH_ID.getRoute(Navigation.getTopmostReportId() ?? '-1'))) {
+ /** Do not trigger the refocusing when the active route is not the report screen */
+ const navigationState = navigationRef.getState();
+ if (!navigationState || (!isReportOpenInRHP(navigationState) && getTopmostRouteName(navigationState) !== SCREENS.REPORT)) {
return;
}
diff --git a/src/libs/ReportActionsUtils.ts b/src/libs/ReportActionsUtils.ts
index 3f8acd0e06fe..85850c15e534 100644
--- a/src/libs/ReportActionsUtils.ts
+++ b/src/libs/ReportActionsUtils.ts
@@ -260,6 +260,18 @@ function isRoomChangeLogAction(reportAction: OnyxEntry): reportAct
return isActionOfType(reportAction, ...Object.values(CONST.REPORT.ACTIONS.TYPE.ROOM_CHANGE_LOG));
}
+function isInviteOrRemovedAction(
+ reportAction: OnyxInputOrEntry,
+): reportAction is ReportAction> {
+ return isActionOfType(
+ reportAction,
+ CONST.REPORT.ACTIONS.TYPE.ROOM_CHANGE_LOG.INVITE_TO_ROOM,
+ CONST.REPORT.ACTIONS.TYPE.ROOM_CHANGE_LOG.REMOVE_FROM_ROOM,
+ CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.INVITE_TO_ROOM,
+ CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.REMOVE_FROM_ROOM,
+ );
+}
+
/**
* Returns whether the comment is a thread parent message/the first message in a thread
*/
@@ -1474,6 +1486,7 @@ export {
isClosedAction,
isRenamedAction,
isRoomChangeLogAction,
+ isInviteOrRemovedAction,
isChronosOOOListAction,
isAddCommentAction,
isPolicyChangeLogAction,
diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts
index edfb048f9da7..afe384b87531 100644
--- a/src/libs/ReportUtils.ts
+++ b/src/libs/ReportUtils.ts
@@ -5,6 +5,7 @@ import lodashEscape from 'lodash/escape';
import lodashFindLastIndex from 'lodash/findLastIndex';
import lodashIntersection from 'lodash/intersection';
import lodashIsEqual from 'lodash/isEqual';
+import lodashMaxBy from 'lodash/maxBy';
import type {OnyxCollection, OnyxEntry, OnyxUpdate} from 'react-native-onyx';
import Onyx from 'react-native-onyx';
import type {OriginalMessageModifiedExpense} from 'src/types/onyx/OriginalMessage';
@@ -1064,7 +1065,7 @@ function isSystemChat(report: OnyxEntry): boolean {
* Only returns true if this is our main 1:1 DM report with Concierge.
*/
function isConciergeChatReport(report: OnyxInputOrEntry): boolean {
- const participantAccountIDs = Object.keys(report?.participants ?? {});
+ const participantAccountIDs = Object.keys(report?.participants ?? {}).filter((accountID) => Number(accountID) !== currentUserAccountID);
return participantAccountIDs.length === 1 && Number(participantAccountIDs[0]) === CONST.ACCOUNT_ID.CONCIERGE && !isChatThread(report);
}
@@ -1096,20 +1097,6 @@ function filterReportsByPolicyIDAndMemberAccountIDs(reports: Array !!report && doesReportBelongToWorkspace(report, policyMemberAccountIDs, policyID));
}
-/**
- * Given an array of reports, return them sorted by the last read timestamp.
- */
-function sortReportsByLastRead(reports: Array>, reportMetadata: OnyxCollection): Array> {
- return reports
- .filter((report) => !!report?.reportID && !!(reportMetadata?.[`${ONYXKEYS.COLLECTION.REPORT_METADATA}${report.reportID}`]?.lastVisitTime ?? report?.lastReadTime))
- .sort((a, b) => {
- const aTime = new Date(reportMetadata?.[`${ONYXKEYS.COLLECTION.REPORT_METADATA}${a?.reportID}`]?.lastVisitTime ?? a?.lastReadTime ?? '');
- const bTime = new Date(reportMetadata?.[`${ONYXKEYS.COLLECTION.REPORT_METADATA}${b?.reportID}`]?.lastVisitTime ?? b?.lastReadTime ?? '');
-
- return aTime.valueOf() - bTime.valueOf();
- });
-}
-
/**
* Returns true if report is still being processed
*/
@@ -1170,6 +1157,13 @@ function hasExpensifyGuidesEmails(accountIDs: number[]): boolean {
return accountIDs.some((accountID) => Str.extractEmailDomain(allPersonalDetails?.[accountID]?.login ?? '') === CONST.EMAIL.GUIDES_DOMAIN);
}
+function getMostRecentlyVisitedReport(reports: Array>, reportMetadata: OnyxCollection): OnyxEntry {
+ const filteredReports = reports.filter(
+ (report) => !!report?.reportID && !!(reportMetadata?.[`${ONYXKEYS.COLLECTION.REPORT_METADATA}${report.reportID}`]?.lastVisitTime ?? report?.lastReadTime),
+ );
+ return lodashMaxBy(filteredReports, (a) => new Date(reportMetadata?.[`${ONYXKEYS.COLLECTION.REPORT_METADATA}${a?.reportID}`]?.lastVisitTime ?? a?.lastReadTime ?? '').valueOf());
+}
+
function findLastAccessedReport(ignoreDomainRooms: boolean, openOnAdminRoom = false, policyID?: string, excludeReportID?: string): OnyxEntry {
// If it's the user's first time using New Expensify, then they could either have:
// - just a Concierge report, if so we'll return that
@@ -1186,11 +1180,9 @@ function findLastAccessedReport(ignoreDomainRooms: boolean, openOnAdminRoom = fa
reportsValues = filterReportsByPolicyIDAndMemberAccountIDs(reportsValues, policyMemberAccountIDs, policyID);
}
- let sortedReports = sortReportsByLastRead(reportsValues, allReportMetadata);
-
let adminReport: OnyxEntry;
if (openOnAdminRoom) {
- adminReport = sortedReports.find((report) => {
+ adminReport = reportsValues.find((report) => {
const chatType = getChatType(report);
return chatType === CONST.REPORT.CHAT_TYPE.POLICY_ADMINS;
});
@@ -1199,7 +1191,7 @@ function findLastAccessedReport(ignoreDomainRooms: boolean, openOnAdminRoom = fa
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
const shouldFilter = excludeReportID || ignoreDomainRooms;
if (shouldFilter) {
- sortedReports = sortedReports.filter((report) => {
+ reportsValues = reportsValues.filter((report) => {
if (excludeReportID && report?.reportID === excludeReportID) {
return false;
}
@@ -1222,22 +1214,25 @@ function findLastAccessedReport(ignoreDomainRooms: boolean, openOnAdminRoom = fa
if (isFirstTimeNewExpensifyUser) {
// Filter out the systemChat report from the reports list, as we don't want to drop the user into that report over Concierge when they first log in
- sortedReports = sortedReports.filter((report) => !isSystemChat(report)) ?? [];
- if (sortedReports.length === 1) {
- return sortedReports[0];
+ reportsValues = reportsValues.filter((report) => !isSystemChat(report)) ?? [];
+ if (reportsValues.length === 1) {
+ return reportsValues[0];
}
- return adminReport ?? sortedReports.find((report) => !isConciergeChatReport(report));
+ return adminReport ?? reportsValues.find((report) => !isConciergeChatReport(report));
}
// If we only have two reports and one of them is the system chat, filter it out so we don't
// overwrite showing the concierge chat
- const hasSystemChat = sortedReports.find((report) => isSystemChat(report)) ?? false;
- if (sortedReports.length === 2 && hasSystemChat) {
- sortedReports = sortedReports.filter((report) => !isSystemChat(report)) ?? [];
+ const hasSystemChat = reportsValues.find((report) => isSystemChat(report)) ?? false;
+ if (reportsValues.length === 2 && hasSystemChat) {
+ reportsValues = reportsValues.filter((report) => !isSystemChat(report)) ?? [];
}
- return adminReport ?? sortedReports.at(-1);
+ // We are getting the last read report from the metadata of the report.
+ const lastRead = getMostRecentlyVisitedReport(reportsValues, allReportMetadata);
+
+ return adminReport ?? lastRead;
}
/**
@@ -4007,11 +4002,19 @@ function buildOptimisticExpenseReport(chatReportID: string, policyID: string, pa
return expenseReport;
}
-function getIOUSubmittedMessage(reportID: string) {
+function getFormattedAmount(reportID: string) {
const report = getReportOrDraftReport(reportID);
const linkedReport = isChatThread(report) ? getParentReport(report) : report;
const formattedAmount = CurrencyUtils.convertToDisplayString(Math.abs(linkedReport?.total ?? 0), linkedReport?.currency);
- return Localize.translateLocal('iou.submittedAmount', {formattedAmount});
+ return formattedAmount;
+}
+
+function getIOUSubmittedMessage(reportID: string) {
+ return Localize.translateLocal('iou.submittedAmount', {formattedAmount: getFormattedAmount(reportID)});
+}
+
+function getIOUApprovedMessage(reportID: string) {
+ return Localize.translateLocal('iou.approvedAmount', {amount: getFormattedAmount(reportID)});
}
/**
@@ -5456,6 +5459,10 @@ function shouldReportBeInOptionList({
return false;
}
+ if (report?.type === CONST.REPORT.TYPE.PAYCHECK || report?.type === CONST.REPORT.TYPE.BILL) {
+ return false;
+ }
+
// Include the currently viewed report. If we excluded the currently viewed report, then there
// would be no way to highlight it in the options list and it would be confusing to users because they lose
// a sense of context.
@@ -7172,6 +7179,7 @@ export {
getGroupChatName,
getIOUReportActionDisplayMessage,
getIOUReportActionMessage,
+ getIOUApprovedMessage,
getIOUSubmittedMessage,
getIcons,
getIconsForParticipants,
@@ -7344,7 +7352,6 @@ export {
shouldShowFlagComment,
shouldShowRBRForMissingSmartscanFields,
shouldUseFullTitleToDisplay,
- sortReportsByLastRead,
updateOptimisticParentReportAction,
updateReportPreview,
temporary_getMoneyRequestOptions,
@@ -7362,6 +7369,7 @@ export {
getChatUsedForOnboarding,
findPolicyExpenseChatByPolicyID,
hasOnlyNonReimbursableTransactions,
+ getMostRecentlyVisitedReport,
};
export type {
diff --git a/src/libs/SearchUtils.ts b/src/libs/SearchUtils.ts
index cb579e44b95d..91d742f44e62 100644
--- a/src/libs/SearchUtils.ts
+++ b/src/libs/SearchUtils.ts
@@ -1,4 +1,4 @@
-import type {ValueOf} from 'react-native-gesture-handler/lib/typescript/typeUtils';
+import type {SearchColumnType, SortOrder} from '@components/Search/types';
import ReportListItem from '@components/SelectionList/Search/ReportListItem';
import TransactionListItem from '@components/SelectionList/Search/TransactionListItem';
import type {ListItem, ReportListItemType, TransactionListItemType} from '@components/SelectionList/types';
@@ -14,9 +14,6 @@ import type {AuthScreensParamList, RootStackParamList, State} from './Navigation
import * as TransactionUtils from './TransactionUtils';
import * as UserUtils from './UserUtils';
-type SortOrder = ValueOf;
-type SearchColumnType = ValueOf;
-
const columnNamesToSortingProperty = {
[CONST.SEARCH.TABLE_COLUMNS.TO]: 'formattedTo' as const,
[CONST.SEARCH.TABLE_COLUMNS.FROM]: 'formattedFrom' as const,
@@ -317,4 +314,3 @@ export {
isTransactionListItemType,
isSearchResultsEmpty,
};
-export type {SearchColumnType, SortOrder};
diff --git a/src/libs/SidebarUtils.ts b/src/libs/SidebarUtils.ts
index 4f227e04482a..9efd1e584052 100644
--- a/src/libs/SidebarUtils.ts
+++ b/src/libs/SidebarUtils.ts
@@ -6,6 +6,7 @@ import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
import type {PersonalDetails, PersonalDetailsList, ReportActions, TransactionViolation} from '@src/types/onyx';
import type Beta from '@src/types/onyx/Beta';
+import type {OriginalMessageChangeLog} from '@src/types/onyx/OriginalMessage';
import type Policy from '@src/types/onyx/Policy';
import type PriorityMode from '@src/types/onyx/PriorityMode';
import type Report from '@src/types/onyx/Report';
@@ -126,21 +127,22 @@ function getOrderedReportIDs(
}
});
- // The LHN is split into four distinct groups, and each group is sorted a little differently. The groups will ALWAYS be in this order:
+ // The LHN is split into five distinct groups, and each group is sorted a little differently. The groups will ALWAYS be in this order:
// 1. Pinned/GBR - Always sorted by reportDisplayName
- // 2. Drafts - Always sorted by reportDisplayName
- // 3. Non-archived reports and settled IOUs
+ // 2. Error reports - Always sorted by reportDisplayName
+ // 3. Drafts - Always sorted by reportDisplayName
+ // 4. Non-archived reports and settled IOUs
// - Sorted by lastVisibleActionCreated in default (most recent) view mode
// - Sorted by reportDisplayName in GSD (focus) view mode
- // 4. Archived reports
+ // 5. Archived reports
// - Sorted by lastVisibleActionCreated in default (most recent) view mode
// - Sorted by reportDisplayName in GSD (focus) view mode
const pinnedAndGBRReports: MiniReport[] = [];
+ const errorReports: MiniReport[] = [];
const draftReports: MiniReport[] = [];
const nonArchivedReports: MiniReport[] = [];
const archivedReports: MiniReport[] = [];
- const errorReports: MiniReport[] = [];
if (currentPolicyID || policyMemberAccountIDs.length > 0) {
reportsToDisplay = reportsToDisplay.filter(
@@ -160,12 +162,12 @@ function getOrderedReportIDs(
const reportAction = ReportActionsUtils.getReportAction(report?.parentReportID ?? '-1', report?.parentReportActionID ?? '-1');
if (isPinned || ReportUtils.requiresAttentionFromCurrentUser(report, reportAction)) {
pinnedAndGBRReports.push(miniReport);
+ } else if (report?.hasErrorsOtherThanFailedReceipt) {
+ errorReports.push(miniReport);
} else if (hasValidDraftComment(report?.reportID ?? '-1')) {
draftReports.push(miniReport);
} else if (ReportUtils.isArchivedRoom(report)) {
archivedReports.push(miniReport);
- } else if (report?.hasErrorsOtherThanFailedReceipt) {
- errorReports.push(miniReport);
} else {
nonArchivedReports.push(miniReport);
}
@@ -352,7 +354,7 @@ function getOptionData({
result.alternateText = Localize.translate(preferredLocale, 'newRoomPage.roomRenamedTo', {newName});
} else if (ReportActionsUtils.isTaskAction(lastAction)) {
result.alternateText = ReportUtils.formatReportLastMessageText(TaskUtils.getTaskReportActionMessage(lastAction).text);
- } else if (ReportActionsUtils.isRoomChangeLogAction(lastAction)) {
+ } else if (ReportActionsUtils.isInviteOrRemovedAction(lastAction)) {
const lastActionOriginalMessage = lastAction?.actionName ? ReportActionsUtils.getOriginalMessage(lastAction) : null;
const targetAccountIDs = lastActionOriginalMessage?.targetAccountIDs ?? [];
const targetAccountIDsLength = targetAccountIDs.length !== 0 ? targetAccountIDs.length : report.lastMessageHtml?.match(/]*><\/mention-user>/g)?.length ?? 0;
@@ -371,11 +373,9 @@ function getOptionData({
: ` ${Localize.translate(preferredLocale, 'workspace.invite.from')}`;
result.alternateText += `${preposition} ${roomName}`;
}
- if (lastActionName === CONST.REPORT.ACTIONS.TYPE.ROOM_CHANGE_LOG.UPDATE_ROOM_DESCRIPTION) {
- result.alternateText = `${lastActorDisplayName} ${Localize.translate(preferredLocale, 'roomChangeLog.updateRoomDescription')} ${
- lastActionOriginalMessage?.description
- }`.trim();
- }
+ } else if (lastActionName === CONST.REPORT.ACTIONS.TYPE.ROOM_CHANGE_LOG.UPDATE_ROOM_DESCRIPTION) {
+ const lastActionOriginalMessage = lastAction?.actionName ? (ReportActionsUtils.getOriginalMessage(lastAction) as OriginalMessageChangeLog | undefined) : null;
+ result.alternateText = `${lastActorDisplayName} ${Localize.translate(preferredLocale, 'roomChangeLog.updateRoomDescription')} ${lastActionOriginalMessage?.description}`.trim();
} else if (lastAction?.actionName === CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.LEAVE_POLICY) {
result.alternateText = Localize.translateLocal('workspace.invite.leftWorkspace');
} else if (lastAction?.actionName !== CONST.REPORT.ACTIONS.TYPE.REPORT_PREVIEW && lastActorDisplayName && lastMessageTextFromReport) {
diff --git a/src/libs/TaskUtils.ts b/src/libs/TaskUtils.ts
index bd0bd10cd83e..06745a49217b 100644
--- a/src/libs/TaskUtils.ts
+++ b/src/libs/TaskUtils.ts
@@ -1,12 +1,21 @@
import type {OnyxEntry} from 'react-native-onyx';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
+import ROUTES from '@src/ROUTES';
import type {Message} from '@src/types/onyx/ReportAction';
import type ReportAction from '@src/types/onyx/ReportAction';
import * as Localize from './Localize';
+import Navigation from './Navigation/Navigation';
import {getReportActionHtml, getReportActionText} from './ReportActionsUtils';
import * as ReportConnection from './ReportConnection';
+/**
+ * Check if the active route belongs to task edit flow.
+ */
+function isActiveTaskEditRoute(reportID: string): boolean {
+ return [ROUTES.TASK_TITLE, ROUTES.TASK_ASSIGNEE, ROUTES.REPORT_DESCRIPTION].map((route) => route.getRoute(reportID)).some(Navigation.isActiveRoute);
+}
+
/**
* Given the Task reportAction name, return the appropriate message to be displayed and copied to clipboard.
*/
@@ -42,4 +51,4 @@ function getTaskCreatedMessage(reportAction: OnyxEntry) {
return taskTitle ? Localize.translateLocal('task.messages.created', {title: taskTitle}) : '';
}
-export {getTaskReportActionMessage, getTaskTitle, getTaskCreatedMessage};
+export {isActiveTaskEditRoute, getTaskReportActionMessage, getTaskTitle, getTaskCreatedMessage};
diff --git a/src/libs/actions/IOU.ts b/src/libs/actions/IOU.ts
index 9e1dbb3c299c..bd4331b19403 100644
--- a/src/libs/actions/IOU.ts
+++ b/src/libs/actions/IOU.ts
@@ -6624,7 +6624,11 @@ function submitReport(expenseReport: OnyxTypes.Report) {
API.write(WRITE_COMMANDS.SUBMIT_REPORT, parameters, {optimisticData, successData, failureData});
}
-function cancelPayment(expenseReport: OnyxTypes.Report, chatReport: OnyxTypes.Report) {
+function cancelPayment(expenseReport: OnyxEntry, chatReport: OnyxTypes.Report) {
+ if (isEmptyObject(expenseReport)) {
+ return;
+ }
+
const optimisticReportAction = ReportUtils.buildOptimisticCancelPaymentReportAction(expenseReport.reportID, -(expenseReport.total ?? 0), expenseReport.currency ?? '');
const policy = PolicyUtils.getPolicy(chatReport.policyID);
const isFree = policy && policy.type === CONST.POLICY.TYPE.FREE;
diff --git a/src/libs/actions/PersonalDetails.ts b/src/libs/actions/PersonalDetails.ts
index 5870d642d8cd..8e2fff3868ae 100644
--- a/src/libs/actions/PersonalDetails.ts
+++ b/src/libs/actions/PersonalDetails.ts
@@ -25,7 +25,6 @@ import ROUTES from '@src/ROUTES';
import type {DateOfBirthForm} from '@src/types/form';
import type {PersonalDetails, PersonalDetailsList} from '@src/types/onyx';
import type {SelectedTimezone, Timezone} from '@src/types/onyx/PersonalDetails';
-import * as Session from './Session';
let currentUserEmail = '';
let currentUserAccountID = -1;
@@ -191,10 +190,6 @@ function updateAddress(street: string, street2: string, city: string, state: str
* selected timezone if set to automatically update.
*/
function updateAutomaticTimezone(timezone: Timezone) {
- if (Session.isAnonymousUser()) {
- return;
- }
-
if (!currentUserAccountID) {
return;
}
diff --git a/src/libs/actions/Policy/ReportField.ts b/src/libs/actions/Policy/ReportField.ts
index bccb08c47c18..27b67c9fe686 100644
--- a/src/libs/actions/Policy/ReportField.ts
+++ b/src/libs/actions/Policy/ReportField.ts
@@ -5,8 +5,8 @@ import * as API from '@libs/API';
import type {
CreateWorkspaceReportFieldListValueParams,
CreateWorkspaceReportFieldParams,
+ DeletePolicyReportField,
EnableWorkspaceReportFieldListValueParams,
- PolicyReportFieldsReplace,
RemoveWorkspaceReportFieldListValueParams,
UpdateWorkspaceReportFieldInitialValueParams,
} from '@libs/API/parameters';
@@ -260,7 +260,7 @@ function deleteReportFields(policyID: string, reportFieldsToUpdate: string[]) {
],
};
- const parameters: PolicyReportFieldsReplace = {
+ const parameters: DeletePolicyReportField = {
policyID,
reportFields: JSON.stringify(Object.values(updatedReportFields)),
};
diff --git a/src/libs/actions/Policy/Tag.ts b/src/libs/actions/Policy/Tag.ts
index 2558969be2f3..9dc0f96c5886 100644
--- a/src/libs/actions/Policy/Tag.ts
+++ b/src/libs/actions/Policy/Tag.ts
@@ -1,4 +1,4 @@
-import type {NullishDeep, OnyxCollection, OnyxEntry} from 'react-native-onyx';
+import type {NullishDeep, OnyxCollection, OnyxEntry, OnyxUpdate} from 'react-native-onyx';
import Onyx from 'react-native-onyx';
import * as API from '@libs/API';
import type {EnablePolicyTagsParams, OpenPolicyTagsPageParams, RenamePolicyTaglistParams, RenamePolicyTagsParams, SetPolicyTagsEnabled, SetPolicyTagsRequired} from '@libs/API/parameters';
@@ -624,6 +624,9 @@ function renamePolicyTaglist(policyID: string, policyTagListName: {oldName: stri
}
function setPolicyRequiresTag(policyID: string, requiresTag: boolean) {
+ const policyTags = allPolicyTags?.[`${ONYXKEYS.COLLECTION.POLICY_TAGS}${policyID}`] ?? {};
+ const isMultiLevelTags = PolicyUtils.isMultiLevelTags(policyTags);
+
const onyxData: OnyxData = {
optimisticData: [
{
@@ -667,6 +670,26 @@ function setPolicyRequiresTag(policyID: string, requiresTag: boolean) {
],
};
+ if (isMultiLevelTags) {
+ const getUpdatedTagsData = (required: boolean): OnyxUpdate => ({
+ key: `${ONYXKEYS.COLLECTION.POLICY_TAGS}${policyID}`,
+ onyxMethod: Onyx.METHOD.MERGE,
+ value: {
+ ...Object.keys(policyTags).reduce((acc, key) => {
+ acc[key] = {
+ ...acc[key],
+ required,
+ };
+ return acc;
+ }, {}),
+ },
+ });
+
+ onyxData.optimisticData?.push(getUpdatedTagsData(requiresTag));
+ onyxData.failureData?.push(getUpdatedTagsData(!requiresTag));
+ onyxData.successData?.push(getUpdatedTagsData(requiresTag));
+ }
+
const parameters = {
policyID,
requiresTag,
diff --git a/src/libs/actions/Report.ts b/src/libs/actions/Report.ts
index 026ce45146d3..3060f53f12c3 100644
--- a/src/libs/actions/Report.ts
+++ b/src/libs/actions/Report.ts
@@ -1,3 +1,4 @@
+import {findFocusedRoute} from '@react-navigation/native';
import {format as timezoneFormat, utcToZonedTime} from 'date-fns-tz';
import {Str} from 'expensify-common';
import isEmpty from 'lodash/isEmpty';
@@ -55,11 +56,13 @@ import {prepareDraftComment} from '@libs/DraftCommentUtils';
import * as EmojiUtils from '@libs/EmojiUtils';
import * as Environment from '@libs/Environment/Environment';
import * as ErrorUtils from '@libs/ErrorUtils';
+import hasCompletedGuidedSetupFlowSelector from '@libs/hasCompletedGuidedSetupFlowSelector';
import isPublicScreenRoute from '@libs/isPublicScreenRoute';
import * as Localize from '@libs/Localize';
import Log from '@libs/Log';
import {registerPaginationConfig} from '@libs/Middleware/Pagination';
-import Navigation from '@libs/Navigation/Navigation';
+import Navigation, {navigationRef} from '@libs/Navigation/Navigation';
+import {isOnboardingFlowName} from '@libs/NavigationUtils';
import type {NetworkStatus} from '@libs/NetworkConnection';
import LocalNotification from '@libs/Notification/LocalNotification';
import Parser from '@libs/Parser';
@@ -2541,28 +2544,47 @@ function openReportFromDeepLink(url: string) {
// Navigate to the report after sign-in/sign-up.
InteractionManager.runAfterInteractions(() => {
Session.waitForUserSignIn().then(() => {
- Navigation.waitForProtectedRoutes().then(() => {
- if (route && Session.isAnonymousUser() && !Session.canAnonymousUserAccessRoute(route)) {
- Session.signOutAndRedirectToSignIn(true);
- return;
- }
-
- // We don't want to navigate to the exitTo route when creating a new workspace from a deep link,
- // because we already handle creating the optimistic policy and navigating to it in App.setUpPoliciesAndNavigate,
- // which is already called when AuthScreens mounts.
- if (new URL(url).searchParams.get('exitTo') === ROUTES.WORKSPACE_NEW) {
- return;
- }
-
- if (shouldSkipDeepLinkNavigation(route)) {
- return;
- }
-
- if (isAuthenticated) {
- return;
- }
-
- Navigation.navigate(route as Route, CONST.NAVIGATION.ACTION_TYPE.PUSH);
+ Onyx.connect({
+ key: ONYXKEYS.NVP_ONBOARDING,
+ callback: (onboarding) => {
+ Navigation.waitForProtectedRoutes().then(() => {
+ if (route && Session.isAnonymousUser() && !Session.canAnonymousUserAccessRoute(route)) {
+ Session.signOutAndRedirectToSignIn(true);
+ return;
+ }
+
+ // We don't want to navigate to the exitTo route when creating a new workspace from a deep link,
+ // because we already handle creating the optimistic policy and navigating to it in App.setUpPoliciesAndNavigate,
+ // which is already called when AuthScreens mounts.
+ if (new URL(url).searchParams.get('exitTo') === ROUTES.WORKSPACE_NEW) {
+ return;
+ }
+
+ if (shouldSkipDeepLinkNavigation(route)) {
+ return;
+ }
+
+ const state = navigationRef.getRootState();
+ const currentFocusedRoute = findFocusedRoute(state);
+ const hasCompletedGuidedSetupFlow = hasCompletedGuidedSetupFlowSelector(onboarding);
+
+ // We need skip deeplinking if the user hasn't completed the guided setup flow.
+ if (!hasCompletedGuidedSetupFlow) {
+ return;
+ }
+
+ if (isOnboardingFlowName(currentFocusedRoute?.name)) {
+ Welcome.setOnboardingErrorMessage(Localize.translateLocal('onboarding.purpose.errorBackButton'));
+ return;
+ }
+
+ if (isAuthenticated) {
+ return;
+ }
+
+ Navigation.navigate(route as Route, CONST.NAVIGATION.ACTION_TYPE.PUSH);
+ });
+ },
});
});
});
diff --git a/src/libs/actions/Search.ts b/src/libs/actions/Search.ts
index 70f7d2d5b7e0..4ce82a027a12 100644
--- a/src/libs/actions/Search.ts
+++ b/src/libs/actions/Search.ts
@@ -63,17 +63,18 @@ function createTransactionThread(hash: number, transactionID: string, reportID:
},
},
};
-
Onyx.merge(`${ONYXKEYS.COLLECTION.SNAPSHOT}${hash}`, onyxUpdate);
}
function holdMoneyRequestOnSearch(hash: number, transactionIDList: string[], comment: string) {
const {optimisticData, finallyData} = getOnyxLoadingData(hash);
+
API.write(WRITE_COMMANDS.HOLD_MONEY_REQUEST_ON_SEARCH, {hash, transactionIDList, comment}, {optimisticData, finallyData});
}
function unholdMoneyRequestOnSearch(hash: number, transactionIDList: string[]) {
const {optimisticData, finallyData} = getOnyxLoadingData(hash);
+
API.write(WRITE_COMMANDS.UNHOLD_MONEY_REQUEST_ON_SEARCH, {hash, transactionIDList}, {optimisticData, finallyData});
}
diff --git a/src/libs/actions/Session/index.ts b/src/libs/actions/Session/index.ts
index 0c362f870da4..903ac6b97ec7 100644
--- a/src/libs/actions/Session/index.ts
+++ b/src/libs/actions/Session/index.ts
@@ -198,12 +198,12 @@ function hasAuthToken(): boolean {
return !!session.authToken;
}
-function signOutAndRedirectToSignIn(shouldResetToHome?: boolean, shouldStashSession?: boolean) {
+function signOutAndRedirectToSignIn(shouldResetToHome?: boolean, shouldStashSession?: boolean, killHybridApp = true) {
Log.info('Redirecting to Sign In because signOut() was called');
hideContextMenu(false);
if (!isAnonymousUser()) {
// In the HybridApp, we want the Old Dot to handle the sign out process
- if (NativeModules.HybridAppModule) {
+ if (NativeModules.HybridAppModule && killHybridApp) {
NativeModules.HybridAppModule.closeReactNativeApp();
return;
}
diff --git a/src/libs/actions/User.ts b/src/libs/actions/User.ts
index 7acc79485f0c..7b3b1abd04ef 100644
--- a/src/libs/actions/User.ts
+++ b/src/libs/actions/User.ts
@@ -13,7 +13,6 @@ import type {
SetContactMethodAsDefaultParams,
SetNameValuePairParams,
UpdateChatPriorityModeParams,
- UpdateFrequentlyUsedEmojisParams,
UpdateNewsletterSubscriptionParams,
UpdatePreferredEmojiSkinToneParams,
UpdateStatusParams,
@@ -37,7 +36,7 @@ import Visibility from '@libs/Visibility';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
import ROUTES from '@src/ROUTES';
-import type {BlockedFromConcierge, CustomStatusDraft, FrequentlyUsedEmoji, Policy} from '@src/types/onyx';
+import type {BlockedFromConcierge, CustomStatusDraft, Policy} from '@src/types/onyx';
import type Login from '@src/types/onyx/Login';
import type {OnyxServerUpdate} from '@src/types/onyx/OnyxUpdatesFromServer';
import type OnyxPersonalDetails from '@src/types/onyx/PersonalDetails';
@@ -655,23 +654,6 @@ function updatePreferredSkinTone(skinTone: number) {
API.write(WRITE_COMMANDS.UPDATE_PREFERRED_EMOJI_SKIN_TONE, parameters, {optimisticData});
}
-/**
- * Sync frequentlyUsedEmojis with Onyx and Server
- */
-function updateFrequentlyUsedEmojis(frequentlyUsedEmojis: FrequentlyUsedEmoji[]) {
- const optimisticData: OnyxUpdate[] = [
- {
- onyxMethod: Onyx.METHOD.SET,
- key: ONYXKEYS.FREQUENTLY_USED_EMOJIS,
- value: frequentlyUsedEmojis,
- },
- ];
-
- const parameters: UpdateFrequentlyUsedEmojisParams = {value: JSON.stringify(frequentlyUsedEmojis)};
-
- API.write(WRITE_COMMANDS.UPDATE_FREQUENTLY_USED_EMOJIS, parameters, {optimisticData});
-}
-
/**
* Sync user chat priority mode with Onyx and Server
* @param mode
@@ -1045,7 +1027,6 @@ export {
setShouldUseStagingServer,
setMuteAllSounds,
clearUserErrorMessage,
- updateFrequentlyUsedEmojis,
joinScreenShare,
clearScreenShareRequest,
generateStatementPDF,
diff --git a/src/libs/actions/Welcome.ts b/src/libs/actions/Welcome.ts
index a90c386d02b6..b592424cfcdf 100644
--- a/src/libs/actions/Welcome.ts
+++ b/src/libs/actions/Welcome.ts
@@ -11,7 +11,8 @@ import ROUTES from '@src/ROUTES';
import type Onboarding from '@src/types/onyx/Onboarding';
import type TryNewDot from '@src/types/onyx/TryNewDot';
-let onboarding: Onboarding | [] | undefined;
+type OnboardingData = Onboarding | [] | undefined;
+
let isLoadingReportData = true;
let tryNewDotData: TryNewDot | undefined;
@@ -30,8 +31,8 @@ let isServerDataReadyPromise = new Promise((resolve) => {
resolveIsReadyPromise = resolve;
});
-let resolveOnboardingFlowStatus: (value?: Promise) => void | undefined;
-let isOnboardingFlowStatusKnownPromise = new Promise((resolve) => {
+let resolveOnboardingFlowStatus: (value?: OnboardingData) => void;
+let isOnboardingFlowStatusKnownPromise = new Promise((resolve) => {
resolveOnboardingFlowStatus = resolve;
});
@@ -45,7 +46,7 @@ function onServerDataReady(): Promise {
}
function isOnboardingFlowCompleted({onCompleted, onNotCompleted}: HasCompletedOnboardingFlowProps) {
- isOnboardingFlowStatusKnownPromise.then(() => {
+ isOnboardingFlowStatusKnownPromise.then((onboarding) => {
if (Array.isArray(onboarding) || onboarding?.hasCompletedGuidedSetupFlow === undefined) {
return;
}
@@ -102,23 +103,7 @@ function handleHybridAppOnboarding() {
}
/**
- * Check that a few requests have completed so that the welcome action can proceed:
- *
- * - Whether we are a first time new expensify user
- * - Whether we have loaded all policies the server knows about
- * - Whether we have loaded all reports the server knows about
- * Check if onboarding data is ready in order to check if the user has completed onboarding or not
- */
-function checkOnboardingDataReady() {
- if (onboarding === undefined) {
- return;
- }
-
- resolveOnboardingFlowStatus?.();
-}
-
-/**
- * Check if user dismissed modal and if report data are loaded
+ * Check if report data are loaded
*/
function checkServerDataReady() {
if (isLoadingReportData) {
@@ -143,6 +128,10 @@ function setOnboardingPurposeSelected(value: OnboardingPurposeType) {
Onyx.set(ONYXKEYS.ONBOARDING_PURPOSE_SELECTED, value ?? null);
}
+function setOnboardingErrorMessage(value: string) {
+ Onyx.set(ONYXKEYS.ONBOARDING_ERROR_MESSAGE, value ?? null);
+}
+
function setOnboardingAdminsChatReportID(adminsChatReportID?: string) {
Onyx.set(ONYXKEYS.ONBOARDING_ADMINS_CHAT_REPORT_ID, adminsChatReportID ?? null);
}
@@ -186,9 +175,7 @@ Onyx.connect({
return;
}
- onboarding = value;
-
- checkOnboardingDataReady();
+ resolveOnboardingFlowStatus(value);
},
});
@@ -213,10 +200,9 @@ function resetAllChecks() {
isServerDataReadyPromise = new Promise((resolve) => {
resolveIsReadyPromise = resolve;
});
- isOnboardingFlowStatusKnownPromise = new Promise((resolve) => {
+ isOnboardingFlowStatusKnownPromise = new Promise((resolve) => {
resolveOnboardingFlowStatus = resolve;
});
- onboarding = undefined;
isLoadingReportData = true;
}
@@ -229,4 +215,5 @@ export {
setOnboardingPolicyID,
completeHybridAppOnboarding,
handleHybridAppOnboarding,
+ setOnboardingErrorMessage,
};
diff --git a/src/libs/hasCompletedGuidedSetupFlowSelector.ts b/src/libs/hasCompletedGuidedSetupFlowSelector.ts
new file mode 100644
index 000000000000..83cde0a0be8c
--- /dev/null
+++ b/src/libs/hasCompletedGuidedSetupFlowSelector.ts
@@ -0,0 +1,12 @@
+import type {OnyxValue} from 'react-native-onyx';
+import type ONYXKEYS from '@src/ONYXKEYS';
+
+function hasCompletedGuidedSetupFlowSelector(onboarding: OnyxValue): boolean {
+ // onboarding is an array for old accounts and accounts created from olddot
+ if (Array.isArray(onboarding)) {
+ return true;
+ }
+ return onboarding?.hasCompletedGuidedSetupFlow ?? false;
+}
+
+export default hasCompletedGuidedSetupFlowSelector;
diff --git a/src/pages/EditReportFieldDate.tsx b/src/pages/EditReportFieldDate.tsx
index 38209ba1083b..06ba24f780ec 100644
--- a/src/pages/EditReportFieldDate.tsx
+++ b/src/pages/EditReportFieldDate.tsx
@@ -24,7 +24,7 @@ type EditReportFieldDatePageProps = {
isRequired: boolean;
/** Callback to fire when the Save button is pressed */
- onSubmit: (form: FormOnyxValues) => void;
+ onSubmit: (form: FormOnyxValues) => void;
};
function EditReportFieldDatePage({fieldName, isRequired, onSubmit, fieldValue, fieldKey}: EditReportFieldDatePageProps) {
@@ -33,8 +33,8 @@ function EditReportFieldDatePage({fieldName, isRequired, onSubmit, fieldValue, f
const inputRef = useRef(null);
const validate = useCallback(
- (value: FormOnyxValues) => {
- const errors: FormInputErrors = {};
+ (value: FormOnyxValues) => {
+ const errors: FormInputErrors = {};
if (isRequired && value[fieldKey].trim() === '') {
errors[fieldKey] = translate('common.error.fieldRequired');
}
@@ -46,7 +46,7 @@ function EditReportFieldDatePage({fieldName, isRequired, onSubmit, fieldValue, f
return (
) => {
+ const handleReportFieldChange = (form: FormOnyxValues) => {
const value = form[fieldKey];
if (isReportFieldTitle) {
ReportActions.updateReportName(report.reportID, value, report.reportName ?? '');
diff --git a/src/pages/EditReportFieldText.tsx b/src/pages/EditReportFieldText.tsx
index d619eb52b695..b855acf3e1c0 100644
--- a/src/pages/EditReportFieldText.tsx
+++ b/src/pages/EditReportFieldText.tsx
@@ -24,7 +24,7 @@ type EditReportFieldTextPageProps = {
isRequired: boolean;
/** Callback to fire when the Save button is pressed */
- onSubmit: (form: FormOnyxValues) => void;
+ onSubmit: (form: FormOnyxValues) => void;
};
function EditReportFieldTextPage({fieldName, onSubmit, fieldValue, isRequired, fieldKey}: EditReportFieldTextPageProps) {
@@ -33,8 +33,8 @@ function EditReportFieldTextPage({fieldName, onSubmit, fieldValue, isRequired, f
const {inputCallbackRef} = useAutoFocusInput();
const validate = useCallback(
- (values: FormOnyxValues) => {
- const errors: FormInputErrors = {};
+ (values: FormOnyxValues) => {
+ const errors: FormInputErrors = {};
if (isRequired && values[fieldKey].trim() === '') {
errors[fieldKey] = translate('common.error.fieldRequired');
}
@@ -46,7 +46,7 @@ function EditReportFieldTextPage({fieldName, onSubmit, fieldValue, isRequired, f
return (
{
@@ -64,10 +62,10 @@ function LogInWithShortLivedAuthTokenPage({route, account}: LogInWithShortLivedA
Session.setAccountError(error);
}
- if (exitTo) {
+ // For HybridApp we have separate logic to handle transitions.
+ if (!NativeModules.HybridAppModule && exitTo) {
Navigation.isNavigationReady().then(() => {
- const url = NativeModules.HybridAppModule ? Navigation.parseHybridAppUrl(exitTo) : (exitTo as Route);
- navigateToExitUrl(url);
+ Navigation.navigate(exitTo as Route);
});
}
// The only dependencies of the effect are based on props.route
diff --git a/src/pages/LogOutPreviousUserPage.tsx b/src/pages/LogOutPreviousUserPage.tsx
index 622a7db6e086..f5b96e2d57c5 100644
--- a/src/pages/LogOutPreviousUserPage.tsx
+++ b/src/pages/LogOutPreviousUserPage.tsx
@@ -5,7 +5,6 @@ import {withOnyx} from 'react-native-onyx';
import type {OnyxEntry} from 'react-native-onyx';
import FullScreenLoadingIndicator from '@components/FullscreenLoadingIndicator';
import {InitialURLContext} from '@components/InitialURLContextProvider';
-import useHybridAppMiddleware from '@hooks/useHybridAppMiddleware';
import * as SessionUtils from '@libs/SessionUtils';
import Navigation from '@navigation/Navigation';
import type {AuthScreensParamList} from '@navigation/types';
@@ -33,7 +32,6 @@ type LogOutPreviousUserPageProps = LogOutPreviousUserPageOnyxProps & StackScreen
// This component should not do any other navigation as that handled in App.setUpPoliciesAndNavigate
function LogOutPreviousUserPage({session, route, isAccountLoading}: LogOutPreviousUserPageProps) {
const initialURL = useContext(InitialURLContext);
- const {navigateToExitUrl} = useHybridAppMiddleware();
useEffect(() => {
const sessionEmail = session?.email;
@@ -42,7 +40,8 @@ function LogOutPreviousUserPage({session, route, isAccountLoading}: LogOutPrevio
const isSupportalLogin = route.params.authTokenType === CONST.AUTH_TOKEN_TYPES.SUPPORT;
if (isLoggingInAsNewUser) {
- SessionActions.signOutAndRedirectToSignIn(false, isSupportalLogin);
+ // We don't want to close react-native app in this particular case.
+ SessionActions.signOutAndRedirectToSignIn(false, isSupportalLogin, false);
return;
}
@@ -78,12 +77,12 @@ function LogOutPreviousUserPage({session, route, isAccountLoading}: LogOutPrevio
// We don't want to navigate to the exitTo route when creating a new workspace from a deep link,
// because we already handle creating the optimistic policy and navigating to it in App.setUpPoliciesAndNavigate,
// which is already called when AuthScreens mounts.
- if (exitTo && exitTo !== ROUTES.WORKSPACE_NEW && !isAccountLoading && !isLoggingInAsNewUser) {
+ // For HybridApp we have separate logic to handle transitions.
+ if (!NativeModules.HybridAppModule && exitTo && exitTo !== ROUTES.WORKSPACE_NEW && !isAccountLoading && !isLoggingInAsNewUser) {
Navigation.isNavigationReady().then(() => {
// remove this screen and navigate to exit route
- const exitUrl = NativeModules.HybridAppModule ? Navigation.parseHybridAppUrl(exitTo) : exitTo;
Navigation.goBack();
- navigateToExitUrl(exitUrl);
+ Navigation.navigate(exitTo);
});
}
// eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps
diff --git a/src/pages/OnboardingPersonalDetails/BaseOnboardingPersonalDetails.tsx b/src/pages/OnboardingPersonalDetails/BaseOnboardingPersonalDetails.tsx
index f5bd14ed7aa1..52e2d817e6db 100644
--- a/src/pages/OnboardingPersonalDetails/BaseOnboardingPersonalDetails.tsx
+++ b/src/pages/OnboardingPersonalDetails/BaseOnboardingPersonalDetails.tsx
@@ -1,4 +1,4 @@
-import React, {useCallback, useState} from 'react';
+import React, {useCallback, useEffect, useState} from 'react';
import {View} from 'react-native';
import {withOnyx} from 'react-native-onyx';
import FormProvider from '@components/Form/FormProvider';
@@ -12,7 +12,6 @@ import Text from '@components/Text';
import TextInput from '@components/TextInput';
import withCurrentUserPersonalDetails from '@components/withCurrentUserPersonalDetails';
import useAutoFocusInput from '@hooks/useAutoFocusInput';
-import useDisableModalDismissOnEscape from '@hooks/useDisableModalDismissOnEscape';
import useLocalize from '@hooks/useLocalize';
import useOnboardingLayout from '@hooks/useOnboardingLayout';
import useThemeStyles from '@hooks/useThemeStyles';
@@ -46,7 +45,9 @@ function BaseOnboardingPersonalDetails({
const [shouldValidateOnChange, setShouldValidateOnChange] = useState(false);
const {accountID} = useSession();
- useDisableModalDismissOnEscape();
+ useEffect(() => {
+ Welcome.setOnboardingErrorMessage('');
+ }, []);
const completeEngagement = useCallback(
(values: FormOnyxValues<'onboardingPersonalDetailsForm'>) => {
diff --git a/src/pages/OnboardingPurpose/BaseOnboardingPurpose.tsx b/src/pages/OnboardingPurpose/BaseOnboardingPurpose.tsx
index 03a4b790bc5f..7304c1822ae9 100644
--- a/src/pages/OnboardingPurpose/BaseOnboardingPurpose.tsx
+++ b/src/pages/OnboardingPurpose/BaseOnboardingPurpose.tsx
@@ -2,7 +2,7 @@ import {useIsFocused} from '@react-navigation/native';
import React, {useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState} from 'react';
import {View} from 'react-native';
import {ScrollView} from 'react-native-gesture-handler';
-import {withOnyx} from 'react-native-onyx';
+import {useOnyx} from 'react-native-onyx';
import FormAlertWithSubmitButton from '@components/FormAlertWithSubmitButton';
import HeaderWithBackButton from '@components/HeaderWithBackButton';
import Icon from '@components/Icon';
@@ -13,7 +13,6 @@ import MenuItemList from '@components/MenuItemList';
import OfflineIndicator from '@components/OfflineIndicator';
import SafeAreaConsumer from '@components/SafeAreaConsumer';
import Text from '@components/Text';
-import useDisableModalDismissOnEscape from '@hooks/useDisableModalDismissOnEscape';
import useLocalize from '@hooks/useLocalize';
import useOnboardingLayout from '@hooks/useOnboardingLayout';
import useTheme from '@hooks/useTheme';
@@ -28,7 +27,8 @@ import type {OnboardingPurposeType} from '@src/CONST';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
import ROUTES from '@src/ROUTES';
-import type {BaseOnboardingPurposeOnyxProps, BaseOnboardingPurposeProps} from './types';
+import isLoadingOnyxValue from '@src/types/utils/isLoadingOnyxValue';
+import type {BaseOnboardingPurposeProps} from './types';
const menuIcons = {
[CONST.ONBOARDING_CHOICES.EMPLOYER]: Illustrations.ReceiptUpload,
@@ -38,15 +38,15 @@ const menuIcons = {
[CONST.ONBOARDING_CHOICES.LOOKING_AROUND]: Illustrations.Binoculars,
};
-function BaseOnboardingPurpose({shouldUseNativeStyles, shouldEnableMaxHeight, onboardingPurposeSelected}: BaseOnboardingPurposeProps) {
+function BaseOnboardingPurpose({shouldUseNativeStyles, shouldEnableMaxHeight}: BaseOnboardingPurposeProps) {
const styles = useThemeStyles();
const {translate} = useLocalize();
const {shouldUseNarrowLayout} = useOnboardingLayout();
const [selectedPurpose, setSelectedPurpose] = useState(undefined);
const {isSmallScreenWidth, windowHeight} = useWindowDimensions();
const theme = useTheme();
-
- useDisableModalDismissOnEscape();
+ const [onboardingPurposeSelected, onboardingPurposeSelectedResult] = useOnyx(ONYXKEYS.ONBOARDING_PURPOSE_SELECTED);
+ const [onboardingErrorMessage, onboardingErrorMessageResult] = useOnyx(ONYXKEYS.ONBOARDING_ERROR_MESSAGE);
const PurposeFooterInstance = ;
@@ -83,8 +83,6 @@ function BaseOnboardingPurpose({shouldUseNativeStyles, shouldEnableMaxHeight, on
Navigation.navigate(ROUTES.ONBOARDING_PERSONAL_DETAILS);
}, [selectedPurpose]);
- const [errorMessage, setErrorMessage] = useState('');
-
const menuItems: MenuItemProps[] = Object.values(CONST.ONBOARDING_CHOICES).map((choice) => {
const translationKey = `onboarding.purpose.${choice}` as const;
const isSelected = selectedPurpose === choice;
@@ -103,7 +101,7 @@ function BaseOnboardingPurpose({shouldUseNativeStyles, shouldEnableMaxHeight, on
numberOfLinesTitle: 0,
onPress: () => {
Welcome.setOnboardingPurposeSelected(choice);
- setErrorMessage('');
+ Welcome.setOnboardingErrorMessage('');
},
};
});
@@ -111,15 +109,18 @@ function BaseOnboardingPurpose({shouldUseNativeStyles, shouldEnableMaxHeight, on
const handleOuterClick = useCallback(() => {
if (!selectedPurpose) {
- setErrorMessage(translate('onboarding.purpose.errorSelection'));
+ Welcome.setOnboardingErrorMessage(translate('onboarding.purpose.errorSelection'));
} else {
- setErrorMessage(translate('onboarding.purpose.errorContinue'));
+ Welcome.setOnboardingErrorMessage(translate('onboarding.purpose.errorContinue'));
}
- }, [selectedPurpose, setErrorMessage, translate]);
+ }, [selectedPurpose, translate]);
const onboardingLocalRef = useRef(null);
useImperativeHandle(isFocused ? OnboardingRefManager.ref : onboardingLocalRef, () => ({handleOuterClick}), [handleOuterClick]);
+ if (isLoadingOnyxValue(onboardingPurposeSelectedResult, onboardingErrorMessageResult)) {
+ return null;
+ }
return (
{({safeAreaPaddingBottomStyle}) => (
@@ -148,14 +149,14 @@ function BaseOnboardingPurpose({shouldUseNativeStyles, shouldEnableMaxHeight, on
buttonText={translate('common.continue')}
onSubmit={() => {
if (!selectedPurpose) {
- setErrorMessage(translate('onboarding.purpose.errorSelection'));
+ Welcome.setOnboardingErrorMessage(translate('onboarding.purpose.errorSelection'));
return;
}
- setErrorMessage('');
+ Welcome.setOnboardingErrorMessage('');
saveAndNavigate();
}}
- message={errorMessage}
- isAlertVisible={!!errorMessage}
+ message={onboardingErrorMessage}
+ isAlertVisible={!!onboardingErrorMessage}
containerStyles={[styles.w100, styles.mb5, styles.mh0, paddingHorizontal]}
/>
@@ -166,10 +167,6 @@ function BaseOnboardingPurpose({shouldUseNativeStyles, shouldEnableMaxHeight, on
BaseOnboardingPurpose.displayName = 'BaseOnboardingPurpose';
-export default withOnyx({
- onboardingPurposeSelected: {
- key: ONYXKEYS.ONBOARDING_PURPOSE_SELECTED,
- },
-})(BaseOnboardingPurpose);
+export default BaseOnboardingPurpose;
export type {BaseOnboardingPurposeProps};
diff --git a/src/pages/OnboardingPurpose/types.ts b/src/pages/OnboardingPurpose/types.ts
index 8c8f11503f1a..17970dbab9a6 100644
--- a/src/pages/OnboardingPurpose/types.ts
+++ b/src/pages/OnboardingPurpose/types.ts
@@ -1,20 +1,11 @@
-import type {OnyxEntry} from 'react-native-onyx';
-import type {OnboardingPurposeType} from '@src/CONST';
-
type OnboardingPurposeProps = Record;
-type BaseOnboardingPurposeOnyxProps = {
- /** Saved onboarding purpose selected by the user */
- onboardingPurposeSelected: OnyxEntry;
-};
-
-type BaseOnboardingPurposeProps = OnboardingPurposeProps &
- BaseOnboardingPurposeOnyxProps & {
- /* Whether to use native styles tailored for native devices */
- shouldUseNativeStyles: boolean;
+type BaseOnboardingPurposeProps = OnboardingPurposeProps & {
+ /* Whether to use native styles tailored for native devices */
+ shouldUseNativeStyles: boolean;
- /** Whether to use the maxHeight (true) or use the 100% of the height (false) */
- shouldEnableMaxHeight: boolean;
- };
+ /** Whether to use the maxHeight (true) or use the 100% of the height (false) */
+ shouldEnableMaxHeight: boolean;
+};
-export type {BaseOnboardingPurposeOnyxProps, BaseOnboardingPurposeProps, OnboardingPurposeProps};
+export type {BaseOnboardingPurposeProps, OnboardingPurposeProps};
diff --git a/src/pages/OnboardingWork/BaseOnboardingWork.tsx b/src/pages/OnboardingWork/BaseOnboardingWork.tsx
index 9b8824300d30..14f9223f6c67 100644
--- a/src/pages/OnboardingWork/BaseOnboardingWork.tsx
+++ b/src/pages/OnboardingWork/BaseOnboardingWork.tsx
@@ -9,7 +9,6 @@ import KeyboardAvoidingView from '@components/KeyboardAvoidingView';
import OfflineIndicator from '@components/OfflineIndicator';
import Text from '@components/Text';
import TextInput from '@components/TextInput';
-import useDisableModalDismissOnEscape from '@hooks/useDisableModalDismissOnEscape';
import useLocalize from '@hooks/useLocalize';
import useOnboardingLayout from '@hooks/useOnboardingLayout';
import useThemeStyles from '@hooks/useThemeStyles';
@@ -33,8 +32,6 @@ function BaseOnboardingWork({shouldUseNativeStyles, onboardingPurposeSelected, o
const {isSmallScreenWidth} = useWindowDimensions();
const {shouldUseNarrowLayout} = useOnboardingLayout();
- useDisableModalDismissOnEscape();
-
const completeEngagement = useCallback(
(values: FormOnyxValues<'onboardingWorkForm'>) => {
if (!onboardingPurposeSelected) {
diff --git a/src/pages/ReportDetailsPage.tsx b/src/pages/ReportDetailsPage.tsx
index c9440ee548af..a585f9c94d67 100644
--- a/src/pages/ReportDetailsPage.tsx
+++ b/src/pages/ReportDetailsPage.tsx
@@ -109,6 +109,7 @@ function ReportDetailsPage({policies, report, session, personalDetails}: ReportD
const [isLastMemberLeavingGroupModalVisible, setIsLastMemberLeavingGroupModalVisible] = useState(false);
const [isDeleteModalVisible, setIsDeleteModalVisible] = useState(false);
const [isUnapproveModalVisible, setIsUnapproveModalVisible] = useState(false);
+ const [isConfirmModalVisible, setIsConfirmModalVisible] = useState(false);
const policy = useMemo(() => policies?.[`${ONYXKEYS.COLLECTION.POLICY}${report?.policyID ?? '-1'}`], [policies, report?.policyID]);
const isPolicyAdmin = useMemo(() => PolicyUtils.isPolicyAdmin(policy), [policy]);
const isPolicyEmployee = useMemo(() => PolicyUtils.isPolicyEmployee(report?.policyID ?? '-1', policies), [report?.policyID, policies]);
@@ -263,6 +264,21 @@ function ReportDetailsPage({policies, report, session, personalDetails}: ReportD
const shouldShowWriteCapability = !isMoneyRequestReport;
const shouldShowMenuItem = shouldShowNotificationPref || shouldShowWriteCapability || (!!report?.visibility && report.chatType !== CONST.REPORT.CHAT_TYPE.INVOICE);
+ const isPayer = ReportUtils.isPayer(session, moneyRequestReport);
+ const isSettled = ReportUtils.isSettled(moneyRequestReport?.reportID ?? '-1');
+
+ const shouldShowCancelPaymentButton = caseID === CASES.MONEY_REPORT && isPayer && isSettled && ReportUtils.isExpenseReport(moneyRequestReport);
+ const [chatReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${moneyRequestReport?.chatReportID ?? '-1'}`);
+
+ const cancelPayment = useCallback(() => {
+ if (!chatReport) {
+ return;
+ }
+
+ IOU.cancelPayment(moneyRequestReport, chatReport);
+ setIsConfirmModalVisible(false);
+ }, [moneyRequestReport, chatReport]);
+
const menuItems: ReportDetailsPageMenuItem[] = useMemo(() => {
const items: ReportDetailsPageMenuItem[] = [];
@@ -356,6 +372,16 @@ function ReportDetailsPage({policies, report, session, personalDetails}: ReportD
}
}
+ if (shouldShowCancelPaymentButton) {
+ items.push({
+ key: CONST.REPORT_DETAILS_MENU_ITEM.CANCEL_PAYMENT,
+ icon: Expensicons.Trashcan,
+ translationKey: 'iou.cancelPayment',
+ isAnonymousAction: false,
+ action: () => setIsConfirmModalVisible(true),
+ });
+ }
+
if (shouldShowLeaveButton) {
items.push({
key: CONST.REPORT_DETAILS_MENU_ITEM.LEAVE_ROOM,
@@ -403,6 +429,7 @@ function ReportDetailsPage({policies, report, session, personalDetails}: ReportD
isCanceledTaskReport,
shouldShowLeaveButton,
activeChatMembers.length,
+ shouldShowCancelPaymentButton,
isPolicyAdmin,
session,
leaveChat,
@@ -710,6 +737,17 @@ function ReportDetailsPage({policies, report, session, personalDetails}: ReportD
confirmText={translate('common.leave')}
cancelText={translate('common.cancel')}
/>
+ setIsConfirmModalVisible(false)}
+ prompt={translate('iou.cancelPaymentConfirmation')}
+ confirmText={translate('iou.cancelPayment')}
+ cancelText={translate('common.dismiss')}
+ danger
+ shouldEnableNewFocusManagement
+ />
diff --git a/src/pages/Search/SearchHoldReasonPage.tsx b/src/pages/Search/SearchHoldReasonPage.tsx
new file mode 100644
index 000000000000..2506f6bf2453
--- /dev/null
+++ b/src/pages/Search/SearchHoldReasonPage.tsx
@@ -0,0 +1,69 @@
+import type {RouteProp} from '@react-navigation/native';
+import React, {useCallback, useEffect} from 'react';
+import type {FormInputErrors, FormOnyxValues} from '@components/Form/types';
+import {useSearchContext} from '@components/Search/SearchContext';
+import useLocalize from '@hooks/useLocalize';
+import Navigation from '@libs/Navigation/Navigation';
+import * as ValidationUtils from '@libs/ValidationUtils';
+import HoldReasonFormView from '@pages/iou/HoldReasonFormView';
+import * as FormActions from '@userActions/FormActions';
+import * as SearchActions from '@src/libs/actions/Search';
+import ONYXKEYS from '@src/ONYXKEYS';
+import type {Route} from '@src/ROUTES';
+import INPUT_IDS from '@src/types/form/MoneyRequestHoldReasonForm';
+
+type SearchHoldReasonPageRouteParams = {
+ /** ID of the transaction the page was opened for */
+ transactionID: string;
+
+ /** Link to previous page */
+ backTo: Route;
+};
+
+type SearchHoldReasonPageProps = {
+ /** Navigation route context info provided by react navigation */
+ route: RouteProp<{params: SearchHoldReasonPageRouteParams}>;
+};
+
+function SearchHoldReasonPage({route}: SearchHoldReasonPageProps) {
+ const {translate} = useLocalize();
+
+ const {currentSearchHash} = useSearchContext();
+ const {transactionID, backTo} = route.params;
+
+ const onSubmit = (values: FormOnyxValues) => {
+ SearchActions.holdMoneyRequestOnSearch(currentSearchHash, [transactionID], values.comment);
+
+ Navigation.goBack();
+ };
+
+ const validate = useCallback(
+ (values: FormOnyxValues) => {
+ const errors: FormInputErrors = ValidationUtils.getFieldRequiredErrors(values, [INPUT_IDS.COMMENT]);
+
+ if (!values.comment) {
+ errors.comment = translate('common.error.fieldRequired');
+ }
+
+ return errors;
+ },
+ [translate],
+ );
+
+ useEffect(() => {
+ FormActions.clearErrors(ONYXKEYS.FORMS.MONEY_REQUEST_HOLD_FORM);
+ FormActions.clearErrorFields(ONYXKEYS.FORMS.MONEY_REQUEST_HOLD_FORM);
+ }, []);
+
+ return (
+
+ );
+}
+
+SearchHoldReasonPage.displayName = 'SearchHoldReasonPage';
+
+export default SearchHoldReasonPage;
diff --git a/src/pages/home/ReportScreen.tsx b/src/pages/home/ReportScreen.tsx
index 414e324484f9..6491245469a1 100644
--- a/src/pages/home/ReportScreen.tsx
+++ b/src/pages/home/ReportScreen.tsx
@@ -171,6 +171,7 @@ function ReportScreen({
const isLoadingReportOnyx = isLoadingOnyxValue(reportResult);
const permissions = useDeepCompareRef(reportOnyx?.permissions);
+ const lastAccessedReportID = ReportUtils.findLastAccessedReport(!canUseDefaultRooms, !!route.params.openOnAdminRoom, activeWorkspaceID)?.reportID;
useEffect(() => {
// Don't update if there is a reportID in the params already
@@ -183,8 +184,6 @@ function ReportScreen({
return;
}
- const lastAccessedReportID = ReportUtils.findLastAccessedReport(!canUseDefaultRooms, !!route.params.openOnAdminRoom, activeWorkspaceID)?.reportID;
-
// It's possible that reports aren't fully loaded yet
// in that case the reportID is undefined
if (!lastAccessedReportID) {
@@ -193,7 +192,7 @@ function ReportScreen({
Log.info(`[ReportScreen] no reportID found in params, setting it to lastAccessedReportID: ${lastAccessedReportID}`);
navigation.setParams({reportID: lastAccessedReportID});
- }, [activeWorkspaceID, canUseDefaultRooms, navigation, route]);
+ }, [lastAccessedReportID, activeWorkspaceID, canUseDefaultRooms, navigation, route]);
/**
* Create a lightweight Report so as to keep the re-rendering as light as possible by
@@ -586,7 +585,7 @@ function ReportScreen({
}
Navigation.dismissModal();
if (Navigation.getTopmostReportId() === prevOnyxReportID) {
- Navigation.setShouldPopAllStateOnUP();
+ Navigation.setShouldPopAllStateOnUP(true);
Navigation.goBack(undefined, false, true);
}
if (prevReport.parentReportID) {
diff --git a/src/pages/home/report/ContextMenu/ContextMenuActions.tsx b/src/pages/home/report/ContextMenu/ContextMenuActions.tsx
index 218c382fd776..f5143fc9ba21 100644
--- a/src/pages/home/report/ContextMenu/ContextMenuActions.tsx
+++ b/src/pages/home/report/ContextMenu/ContextMenuActions.tsx
@@ -22,12 +22,14 @@ import Navigation from '@libs/Navigation/Navigation';
import Parser from '@libs/Parser';
import ReportActionComposeFocusManager from '@libs/ReportActionComposeFocusManager';
import * as ReportActionsUtils from '@libs/ReportActionsUtils';
+import * as ReportConnection from '@libs/ReportConnection';
import * as ReportUtils from '@libs/ReportUtils';
import * as TaskUtils from '@libs/TaskUtils';
import * as Download from '@userActions/Download';
import * as Report from '@userActions/Report';
import CONST from '@src/CONST';
import type {TranslationPaths} from '@src/languages/types';
+import ONYXKEYS from '@src/ONYXKEYS';
import ROUTES from '@src/ROUTES';
import type {Beta, OnyxInputOrEntry, ReportAction, ReportActionReactions, Transaction} from '@src/types/onyx';
import type IconAsset from '@src/types/utils/IconAsset';
@@ -400,6 +402,9 @@ const ContextMenuActions: ContextMenuAction[] = [
} else if (reportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.SUBMITTED) {
const displayMessage = ReportUtils.getIOUSubmittedMessage(reportID);
Clipboard.setString(displayMessage);
+ } else if (reportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.APPROVED) {
+ const displayMessage = ReportUtils.getIOUApprovedMessage(reportID);
+ Clipboard.setString(displayMessage);
} else if (reportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.HOLD) {
Clipboard.setString(Localize.translateLocal('iou.heldExpense'));
} else if (reportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.UNHOLD) {
@@ -524,6 +529,20 @@ const ContextMenuActions: ContextMenuAction[] = [
},
getDescription: () => {},
},
+ {
+ isAnonymousAction: true,
+ textTranslateKey: 'reportActionContextMenu.copyOnyxData',
+ icon: Expensicons.Copy,
+ successTextTranslateKey: 'reportActionContextMenu.copied',
+ successIcon: Expensicons.Checkmark,
+ shouldShow: (type) => type === CONST.CONTEXT_MENU_TYPES.REPORT && (Environment.isDevelopment() || Environment.isStaging() || Environment.isInternalTestBuild()),
+ onPress: (closePopover, {reportID}) => {
+ const report = ReportConnection.getAllReports()?.[`${ONYXKEYS.COLLECTION.REPORT}${reportID}`];
+ Clipboard.setString(JSON.stringify(report, null, 4));
+ hideContextMenu(true, ReportActionComposeFocusManager.focus);
+ },
+ getDescription: () => {},
+ },
{
isAnonymousAction: false,
textTranslateKey: 'reportActionContextMenu.deleteAction',
diff --git a/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.tsx b/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.tsx
index 6cc55c825983..1df45303694a 100644
--- a/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.tsx
+++ b/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.tsx
@@ -53,7 +53,6 @@ import * as EmojiPickerActions from '@userActions/EmojiPickerAction';
import * as InputFocus from '@userActions/InputFocus';
import * as Modal from '@userActions/Modal';
import * as Report from '@userActions/Report';
-import * as User from '@userActions/User';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
import type * as OnyxTypes from '@src/types/onyx';
@@ -304,22 +303,12 @@ function ComposerWithSuggestions(
const [composerHeight, setComposerHeight] = useState(0);
const textInputRef = useRef(null);
- const insertedEmojisRef = useRef([]);
const syncSelectionWithOnChangeTextRef = useRef(null);
// The ref to check whether the comment saving is in progress
const isCommentPendingSaved = useRef(false);
- /**
- * Update frequently used emojis list. We debounce this method in the constructor so that UpdateFrequentlyUsedEmojis
- * API is not called too often.
- */
- const debouncedUpdateFrequentlyUsedEmojis = useCallback(() => {
- User.updateFrequentlyUsedEmojis(EmojiUtils.getFrequentlyUsedEmojis(insertedEmojisRef.current));
- insertedEmojisRef.current = [];
- }, []);
-
/**
* Set the TextInput Ref
*/
@@ -421,8 +410,6 @@ function ComposerWithSuggestions(
if (suggestionsRef.current) {
suggestionsRef.current.resetSuggestions();
}
- insertedEmojisRef.current = [...insertedEmojisRef.current, ...newEmojis];
- debouncedUpdateFrequentlyUsedEmojis();
}
}
const newCommentConverted = convertToLTRForComposer(newComment);
@@ -461,18 +448,7 @@ function ComposerWithSuggestions(
debouncedBroadcastUserIsTyping(reportID);
}
},
- [
- debouncedUpdateFrequentlyUsedEmojis,
- findNewlyAddedChars,
- preferredLocale,
- preferredSkinTone,
- reportID,
- setIsCommentEmpty,
- suggestionsRef,
- raiseIsScrollLikelyLayoutTriggered,
- debouncedSaveReportComment,
- selection.end,
- ],
+ [findNewlyAddedChars, preferredLocale, preferredSkinTone, reportID, setIsCommentEmpty, suggestionsRef, raiseIsScrollLikelyLayoutTriggered, debouncedSaveReportComment, selection.end],
);
const prepareCommentAndResetComposer = useCallback((): string => {
diff --git a/src/pages/home/report/ReportActionItem.tsx b/src/pages/home/report/ReportActionItem.tsx
index e732c5793f96..9135e494794e 100644
--- a/src/pages/home/report/ReportActionItem.tsx
+++ b/src/pages/home/report/ReportActionItem.tsx
@@ -621,6 +621,8 @@ function ReportActionItem({
children = ;
} else if (action.actionName === CONST.REPORT.ACTIONS.TYPE.SUBMITTED) {
children = ;
+ } else if (action.actionName === CONST.REPORT.ACTIONS.TYPE.APPROVED) {
+ children = ;
} else if (action.actionName === CONST.REPORT.ACTIONS.TYPE.HOLD) {
children = ;
} else if (action.actionName === CONST.REPORT.ACTIONS.TYPE.HOLD_COMMENT) {
diff --git a/src/pages/home/report/ReportActionItemMessageEdit.tsx b/src/pages/home/report/ReportActionItemMessageEdit.tsx
index 6618b20a5a6a..cb68410131ce 100644
--- a/src/pages/home/report/ReportActionItemMessageEdit.tsx
+++ b/src/pages/home/report/ReportActionItemMessageEdit.tsx
@@ -37,7 +37,6 @@ import * as ComposerActions from '@userActions/Composer';
import * as EmojiPickerAction from '@userActions/EmojiPickerAction';
import * as InputFocus from '@userActions/InputFocus';
import * as Report from '@userActions/Report';
-import * as User from '@userActions/User';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
import type * as OnyxTypes from '@src/types/onyx';
@@ -105,7 +104,6 @@ function ReportActionItemMessageEdit(
const textInputRef = useRef<(HTMLTextAreaElement & TextInput) | null>(null);
const isFocusedRef = useRef(false);
- const insertedEmojis = useRef([]);
const draftRef = useRef(draft);
const emojiPickerSelectionRef = useRef(undefined);
// The ref to check whether the comment saving is in progress
@@ -214,19 +212,6 @@ function ReportActionItemMessageEdit(
[debouncedSaveDraft],
);
- /**
- * Update frequently used emojis list. We debounce this method in the constructor so that UpdateFrequentlyUsedEmojis
- * API is not called too often.
- */
- const debouncedUpdateFrequentlyUsedEmojis = useMemo(
- () =>
- lodashDebounce(() => {
- User.updateFrequentlyUsedEmojis(EmojiUtils.getFrequentlyUsedEmojis(insertedEmojis.current));
- insertedEmojis.current = [];
- }, 1000),
- [],
- );
-
/**
* Update the value of the draft in Onyx
*
@@ -236,13 +221,6 @@ function ReportActionItemMessageEdit(
(newDraftInput: string) => {
const {text: newDraft, emojis, cursorPosition} = EmojiUtils.replaceAndExtractEmojis(newDraftInput, preferredSkinTone, preferredLocale);
- if (emojis?.length > 0) {
- const newEmojis = EmojiUtils.getAddedEmojis(emojis, emojisPresentBefore.current);
- if (newEmojis?.length > 0) {
- insertedEmojis.current = [...insertedEmojis.current, ...newEmojis];
- debouncedUpdateFrequentlyUsedEmojis();
- }
- }
emojisPresentBefore.current = emojis;
setDraft(newDraft);
@@ -261,7 +239,7 @@ function ReportActionItemMessageEdit(
debouncedSaveDraft(newDraft);
isCommentPendingSaved.current = true;
},
- [debouncedSaveDraft, debouncedUpdateFrequentlyUsedEmojis, preferredSkinTone, preferredLocale, selection.end],
+ [debouncedSaveDraft, preferredSkinTone, preferredLocale, selection.end],
);
useEffect(() => {
diff --git a/src/pages/iou/HoldReasonFormView.tsx b/src/pages/iou/HoldReasonFormView.tsx
new file mode 100644
index 000000000000..197d00b6b0ed
--- /dev/null
+++ b/src/pages/iou/HoldReasonFormView.tsx
@@ -0,0 +1,72 @@
+import React from 'react';
+import {View} from 'react-native';
+import FormProvider from '@components/Form/FormProvider';
+import InputWrapper from '@components/Form/InputWrapper';
+import type {FormOnyxValues} from '@components/Form/types';
+import HeaderWithBackButton from '@components/HeaderWithBackButton';
+import ScreenWrapper from '@components/ScreenWrapper';
+import Text from '@components/Text';
+import TextInput from '@components/TextInput';
+import useAutoFocusInput from '@hooks/useAutoFocusInput';
+import useLocalize from '@hooks/useLocalize';
+import useThemeStyles from '@hooks/useThemeStyles';
+import Navigation from '@libs/Navigation/Navigation';
+import type ONYXKEYS from '@src/ONYXKEYS';
+import type {Route} from '@src/ROUTES';
+import INPUT_IDS from '@src/types/form/MoneyRequestHoldReasonForm';
+
+type HoldReasonFormViewProps = {
+ /** Submit function for submitting form */
+ onSubmit: (values: FormOnyxValues) => void;
+
+ /** Submit function for validating form */
+ validate: (values: FormOnyxValues) => Partial>;
+
+ /** Link to previous page */
+ backTo: Route;
+};
+
+function HoldReasonFormView({backTo, validate, onSubmit}: HoldReasonFormViewProps) {
+ const styles = useThemeStyles();
+ const {translate} = useLocalize();
+ const {inputCallbackRef} = useAutoFocusInput();
+
+ return (
+
+ Navigation.goBack(backTo)}
+ />
+
+ {translate('iou.explainHold')}
+
+
+
+
+
+ );
+}
+
+HoldReasonFormView.displayName = 'HoldReasonFormViewProps';
+
+export default HoldReasonFormView;
diff --git a/src/pages/iou/HoldReasonPage.tsx b/src/pages/iou/HoldReasonPage.tsx
index 2fc789f77c6c..82f29acf7d10 100644
--- a/src/pages/iou/HoldReasonPage.tsx
+++ b/src/pages/iou/HoldReasonPage.tsx
@@ -1,17 +1,8 @@
import type {RouteProp} from '@react-navigation/native';
import React, {useCallback, useEffect} from 'react';
-import {View} from 'react-native';
import {useOnyx} from 'react-native-onyx';
-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 TextInput from '@components/TextInput';
-import useAutoFocusInput from '@hooks/useAutoFocusInput';
import useLocalize from '@hooks/useLocalize';
-import useThemeStyles from '@hooks/useThemeStyles';
import * as ErrorUtils from '@libs/ErrorUtils';
import Navigation from '@libs/Navigation/Navigation';
import * as ReportActionsUtils from '@libs/ReportActionsUtils';
@@ -22,6 +13,7 @@ import * as IOU from '@userActions/IOU';
import ONYXKEYS from '@src/ONYXKEYS';
import type {Route} from '@src/ROUTES';
import INPUT_IDS from '@src/types/form/MoneyRequestHoldReasonForm';
+import HoldReasonFormView from './HoldReasonFormView';
type HoldReasonPageRouteParams = {
/** ID of the transaction the page was opened for */
@@ -40,9 +32,7 @@ type HoldReasonPageProps = {
};
function HoldReasonPage({route}: HoldReasonPageProps) {
- const styles = useThemeStyles();
const {translate} = useLocalize();
- const {inputCallbackRef} = useAutoFocusInput();
const {transactionID, reportID, backTo} = route.params;
@@ -53,10 +43,6 @@ function HoldReasonPage({route}: HoldReasonPageProps) {
const isWorkspaceRequest = ReportUtils.isReportInGroupPolicy(report);
const parentReportAction = ReportActionsUtils.getReportAction(report?.parentReportID ?? '-1', report?.parentReportActionID ?? '-1');
- const navigateBack = () => {
- Navigation.navigate(backTo);
- };
-
const onSubmit = (values: FormOnyxValues) => {
// We have extra isWorkspaceRequest condition since, for 1:1 requests, canEditMoneyRequest will rightly return false
// as we do not allow requestee to edit fields like description and amount.
@@ -66,7 +52,7 @@ function HoldReasonPage({route}: HoldReasonPageProps) {
}
IOU.putOnHold(transactionID, values.comment, reportID);
- navigateBack();
+ Navigation.navigate(backTo);
};
const validate = useCallback(
@@ -96,38 +82,11 @@ function HoldReasonPage({route}: HoldReasonPageProps) {
}, []);
return (
-
- Navigation.goBack(backTo)}
- />
-
- {translate('iou.explainHold')}
-
-
-
-
-
+
);
}
diff --git a/src/pages/iou/request/step/IOURequestStepDistance.tsx b/src/pages/iou/request/step/IOURequestStepDistance.tsx
index 05b228c73c76..9235c3293a6d 100644
--- a/src/pages/iou/request/step/IOURequestStepDistance.tsx
+++ b/src/pages/iou/request/step/IOURequestStepDistance.tsx
@@ -90,7 +90,8 @@ function IOURequestStepDistance({
const previousValidatedWaypoints = usePrevious(validatedWaypoints);
const haveValidatedWaypointsChanged = !isEqual(previousValidatedWaypoints, validatedWaypoints);
const isRouteAbsentWithoutErrors = !hasRoute && !hasRouteError;
- const shouldFetchRoute = (isRouteAbsentWithoutErrors || haveValidatedWaypointsChanged) && !isLoadingRoute && Object.keys(validatedWaypoints).length > 1;
+ const isEmptyCoordinates = !transaction?.routes?.route0?.geometry?.coordinates?.length;
+ const shouldFetchRoute = (isEmptyCoordinates || isRouteAbsentWithoutErrors || haveValidatedWaypointsChanged) && !isLoadingRoute && Object.keys(validatedWaypoints).length > 1;
const [shouldShowAtLeastTwoDifferentWaypointsError, setShouldShowAtLeastTwoDifferentWaypointsError] = useState(false);
const nonEmptyWaypointsCount = useMemo(() => Object.keys(waypoints).filter((key) => !isEmpty(waypoints[key])).length, [waypoints]);
const duplicateWaypointsError = useMemo(
diff --git a/src/pages/settings/ExitSurvey/ExitSurveyConfirmPage.tsx b/src/pages/settings/ExitSurvey/ExitSurveyConfirmPage.tsx
index a61875e76c2b..db9f3199954f 100644
--- a/src/pages/settings/ExitSurvey/ExitSurveyConfirmPage.tsx
+++ b/src/pages/settings/ExitSurvey/ExitSurveyConfirmPage.tsx
@@ -10,7 +10,6 @@ import HeaderWithBackButton from '@components/HeaderWithBackButton';
import {MushroomTopHat} from '@components/Icon/Illustrations';
import ScreenWrapper from '@components/ScreenWrapper';
import Text from '@components/Text';
-import useHybridAppMiddleware from '@hooks/useHybridAppMiddleware';
import useLocalize from '@hooks/useLocalize';
import useNetwork from '@hooks/useNetwork';
import useThemeStyles from '@hooks/useThemeStyles';
@@ -36,7 +35,6 @@ type ExitSurveyConfirmPageProps = ExitSurveyConfirmPageOnyxProps & StackScreenPr
function ExitSurveyConfirmPage({exitReason, isLoading, route, navigation}: ExitSurveyConfirmPageProps) {
const {translate} = useLocalize();
- const {showSplashScreenOnNextStart} = useHybridAppMiddleware();
const {isOffline} = useNetwork();
const styles = useThemeStyles();
@@ -89,7 +87,6 @@ function ExitSurveyConfirmPage({exitReason, isLoading, route, navigation}: ExitS
ExitSurvey.switchToOldDot().then(() => {
if (NativeModules.HybridAppModule) {
Navigation.resetToHome();
- showSplashScreenOnNextStart();
NativeModules.HybridAppModule.closeReactNativeApp();
return;
}
diff --git a/src/pages/signin/SignInPage.tsx b/src/pages/signin/SignInPage.tsx
index 0b4310cce337..024d5a7c5610 100644
--- a/src/pages/signin/SignInPage.tsx
+++ b/src/pages/signin/SignInPage.tsx
@@ -266,6 +266,7 @@ function SignInPage({credentials, account, activeClients = [], preferredLocale,
diff --git a/src/pages/workspace/accounting/PolicyAccountingPage.tsx b/src/pages/workspace/accounting/PolicyAccountingPage.tsx
index 2c7b93eca0db..400d54d0e005 100644
--- a/src/pages/workspace/accounting/PolicyAccountingPage.tsx
+++ b/src/pages/workspace/accounting/PolicyAccountingPage.tsx
@@ -152,7 +152,7 @@ function PolicyAccountingPage({policy, connectionSyncProgress}: PolicyAccounting
const lastSyncProgressDate = parseISO(connectionSyncProgress?.timestamp ?? '');
const isSyncInProgress =
!!connectionSyncProgress?.stageInProgress &&
- connectionSyncProgress.stageInProgress !== CONST.POLICY.CONNECTIONS.SYNC_STAGE_NAME.JOB_DONE &&
+ (connectionSyncProgress.stageInProgress !== CONST.POLICY.CONNECTIONS.SYNC_STAGE_NAME.JOB_DONE || !policy.connections?.[connectionSyncProgress.connectionName]) &&
isValid(lastSyncProgressDate) &&
differenceInMinutes(new Date(), lastSyncProgressDate) < CONST.POLICY.CONNECTIONS.SYNC_STAGE_TIMEOUT_MINUTES;
diff --git a/src/pages/workspace/accounting/ReconciliationAccountSettingsPage.tsx b/src/pages/workspace/accounting/ReconciliationAccountSettingsPage.tsx
new file mode 100644
index 000000000000..7f0e45678471
--- /dev/null
+++ b/src/pages/workspace/accounting/ReconciliationAccountSettingsPage.tsx
@@ -0,0 +1,81 @@
+import type {StackScreenProps} from '@react-navigation/stack';
+import React, {useMemo} from 'react';
+import {useOnyx} from 'react-native-onyx';
+import ConnectionLayout from '@components/ConnectionLayout';
+import SelectionList from '@components/SelectionList';
+import RadioListItem from '@components/SelectionList/RadioListItem';
+import Text from '@components/Text';
+import TextLink from '@components/TextLink';
+import useLocalize from '@hooks/useLocalize';
+import useThemeStyles from '@hooks/useThemeStyles';
+import type {SettingsNavigatorParamList} from '@navigation/types';
+import CONST from '@src/CONST';
+import ONYXKEYS from '@src/ONYXKEYS';
+import type SCREENS from '@src/SCREENS';
+
+type ReconciliationAccountSettingsPageProps = StackScreenProps;
+
+function ReconciliationAccountSettingsPage({route}: ReconciliationAccountSettingsPageProps) {
+ const {policyID, connection} = route.params;
+ const settlementAccountEnding = '1234'; // TODO: use correct settlement account ending value https://github.com/Expensify/App/issues/44313
+
+ const styles = useThemeStyles();
+ const {translate} = useLocalize();
+
+ const [bankAccountList] = useOnyx(ONYXKEYS.BANK_ACCOUNT_LIST);
+ const [reconciliationConnection] = useOnyx(`${ONYXKEYS.COLLECTION.SHARED_NVP_EXPENSIFY_CARD_CONTINUOUS_RECONCILIATION_CONNECTION}${policyID}`);
+
+ const selectedBankAccount = useMemo(() => reconciliationConnection ?? Object.values(bankAccountList ?? {})[0], [reconciliationConnection, bankAccountList]);
+
+ const sections = useMemo(() => {
+ const data = Object.values(bankAccountList ?? {}).map((bankAccount) => ({
+ text: bankAccount.title,
+ value: bankAccount.accountData?.bankAccountID,
+ keyForList: bankAccount.accountData?.bankAccountID?.toString(),
+ isSelected: bankAccount.accountData?.bankAccountID === selectedBankAccount?.accountData?.bankAccountID,
+ }));
+ return [{data}];
+ }, [bankAccountList, selectedBankAccount]);
+
+ const selectBankAccount = () => {
+ // TODO: add API call when it's implemented https://github.com/Expensify/Expensify/issues/407836
+ // Navigation.goBack();
+ };
+
+ return (
+
+ {translate('workspace.accounting.chooseReconciliationAccount.chooseBankAccount')}
+
+ {translate('workspace.accounting.chooseReconciliationAccount.accountMatches')}
+ {
+ // TODO: navigate to Settlement Account https://github.com/Expensify/App/issues/44313
+ }}
+ >
+ {translate('workspace.accounting.chooseReconciliationAccount.settlementAccount')}
+
+ {translate('workspace.accounting.chooseReconciliationAccount.reconciliationWorks', settlementAccountEnding)}
+
+
+
+
+ );
+}
+
+ReconciliationAccountSettingsPage.displayName = 'ReconciliationAccountSettingsPage';
+
+export default ReconciliationAccountSettingsPage;
diff --git a/src/pages/workspace/categories/WorkspaceCategoriesPage.tsx b/src/pages/workspace/categories/WorkspaceCategoriesPage.tsx
index 633d1833e43f..174251a80d5f 100644
--- a/src/pages/workspace/categories/WorkspaceCategoriesPage.tsx
+++ b/src/pages/workspace/categories/WorkspaceCategoriesPage.tsx
@@ -7,6 +7,7 @@ import Button from '@components/Button';
import ButtonWithDropdownMenu from '@components/ButtonWithDropdownMenu';
import type {DropdownOption} from '@components/ButtonWithDropdownMenu/types';
import ConfirmModal from '@components/ConfirmModal';
+import EmptyStateComponent from '@components/EmptyStateComponent';
import HeaderWithBackButton from '@components/HeaderWithBackButton';
import * as Expensicons from '@components/Icon/Expensicons';
import * as Illustrations from '@components/Icon/Illustrations';
@@ -15,9 +16,9 @@ import SelectionList from '@components/SelectionList';
import ListItemRightCaretWithLabel from '@components/SelectionList/ListItemRightCaretWithLabel';
import TableListItem from '@components/SelectionList/TableListItem';
import type {ListItem} from '@components/SelectionList/types';
+import TableListItemSkeleton from '@components/Skeletons/TableRowSkeleton';
import Text from '@components/Text';
import TextLink from '@components/TextLink';
-import WorkspaceEmptyStateSection from '@components/WorkspaceEmptyStateSection';
import useEnvironment from '@hooks/useEnvironment';
import useLocalize from '@hooks/useLocalize';
import useNetwork from '@hooks/useNetwork';
@@ -317,10 +318,15 @@ function WorkspaceCategoriesPage({route}: WorkspaceCategoriesPageProps) {
color={theme.spinner}
/>
)}
+
{!hasVisibleCategories && !isLoading && (
-
)}
diff --git a/src/pages/workspace/reportFields/CreateReportFieldPage.tsx b/src/pages/workspace/reportFields/CreateReportFieldsPage.tsx
similarity index 95%
rename from src/pages/workspace/reportFields/CreateReportFieldPage.tsx
rename to src/pages/workspace/reportFields/CreateReportFieldsPage.tsx
index aaf3877f9490..4a02e701b058 100644
--- a/src/pages/workspace/reportFields/CreateReportFieldPage.tsx
+++ b/src/pages/workspace/reportFields/CreateReportFieldsPage.tsx
@@ -28,16 +28,16 @@ import INPUT_IDS from '@src/types/form/WorkspaceReportFieldForm';
import InitialListValueSelector from './InitialListValueSelector';
import TypeSelector from './TypeSelector';
-type CreateReportFieldPageProps = WithPolicyAndFullscreenLoadingProps & StackScreenProps;
+type CreateReportFieldsPageProps = WithPolicyAndFullscreenLoadingProps & StackScreenProps;
const defaultDate = DateUtils.extractDate(new Date().toString());
-function CreateReportFieldPage({
+function CreateReportFieldsPage({
policy,
route: {
params: {policyID},
},
-}: CreateReportFieldPageProps) {
+}: CreateReportFieldsPageProps) {
const styles = useThemeStyles();
const {translate} = useLocalize();
const formRef = useRef(null);
@@ -101,7 +101,7 @@ function CreateReportFieldPage({
Navigation.navigate(ROUTES.WORKSPACE_REPORT_FIELD_LIST_VALUES.getRoute(policyID))}
+ onPress={() => Navigation.navigate(ROUTES.WORKSPACE_REPORT_FIELDS_LIST_VALUES.getRoute(policyID))}
/>
)}
@@ -190,6 +190,6 @@ function CreateReportFieldPage({
);
}
-CreateReportFieldPage.displayName = 'CreateReportFieldPage';
+CreateReportFieldsPage.displayName = 'CreateReportFieldsPage';
-export default withPolicyAndFullscreenLoading(CreateReportFieldPage);
+export default withPolicyAndFullscreenLoading(CreateReportFieldsPage);
diff --git a/src/pages/workspace/reportFields/ReportFieldAddListValuePage.tsx b/src/pages/workspace/reportFields/ReportFieldsAddListValuePage.tsx
similarity index 90%
rename from src/pages/workspace/reportFields/ReportFieldAddListValuePage.tsx
rename to src/pages/workspace/reportFields/ReportFieldsAddListValuePage.tsx
index 576dd1a6f6b0..a0969160365e 100644
--- a/src/pages/workspace/reportFields/ReportFieldAddListValuePage.tsx
+++ b/src/pages/workspace/reportFields/ReportFieldsAddListValuePage.tsx
@@ -21,13 +21,13 @@ import ONYXKEYS from '@src/ONYXKEYS';
import type SCREENS from '@src/SCREENS';
import INPUT_IDS from '@src/types/form/WorkspaceReportFieldForm';
-type ReportFieldAddListValuePageProps = StackScreenProps;
+type ReportFieldsAddListValuePageProps = StackScreenProps;
-function ReportFieldAddListValuePage({
+function ReportFieldsAddListValuePage({
route: {
params: {policyID, reportFieldID},
},
-}: ReportFieldAddListValuePageProps) {
+}: ReportFieldsAddListValuePageProps) {
const styles = useThemeStyles();
const {translate} = useLocalize();
const {inputCallbackRef} = useAutoFocusInput();
@@ -61,7 +61,7 @@ function ReportFieldAddListValuePage({
;
+type ReportFieldsEditValuePageProps = StackScreenProps;
-function ReportFieldEditValuePage({
+function ReportFieldsEditValuePage({
route: {
params: {policyID, valueIndex},
},
-}: ReportFieldEditValuePageProps) {
+}: ReportFieldsEditValuePageProps) {
const styles = useThemeStyles();
const {translate} = useLocalize();
const {inputCallbackRef} = useAutoFocusInput();
@@ -67,7 +67,7 @@ function ReportFieldEditValuePage({
;
-function ReportFieldInitialValuePage({
+type ReportFieldsInitialValuePageProps = WithPolicyAndFullscreenLoadingProps & StackScreenProps;
+function ReportFieldsInitialValuePage({
policy,
route: {
params: {policyID, reportFieldID},
},
-}: ReportFieldInitialValuePagePageProps) {
+}: ReportFieldsInitialValuePageProps) {
const styles = useThemeStyles();
const {translate} = useLocalize();
const {inputCallbackRef} = useAutoFocusInput();
@@ -98,7 +98,7 @@ function ReportFieldInitialValuePage({
;
+type ReportFieldsListValuesPageProps = WithPolicyAndFullscreenLoadingProps & StackScreenProps;
-function ReportFieldListValuesPage({
+function ReportFieldsListValuesPage({
policy,
route: {
params: {policyID, reportFieldID},
},
-}: ReportFieldListValuesPageProps) {
+}: ReportFieldsListValuesPageProps) {
const styles = useThemeStyles();
const {translate} = useLocalize();
const {isSmallScreenWidth} = useWindowDimensions();
@@ -140,7 +140,7 @@ function ReportFieldListValuesPage({
return;
}
- Navigation.navigate(ROUTES.WORKSPACE_REPORT_FIELD_VALUE_SETTINGS.getRoute(policyID, valueItem.index, reportFieldID));
+ Navigation.navigate(ROUTES.WORKSPACE_REPORT_FIELDS_VALUE_SETTINGS.getRoute(policyID, valueItem.index, reportFieldID));
setSelectedValues({});
};
@@ -248,7 +248,7 @@ function ReportFieldListValuesPage({
success
icon={Expensicons.Plus}
text={translate('workspace.reportFields.addValue')}
- onPress={() => Navigation.navigate(ROUTES.WORKSPACE_REPORT_FIELD_ADD_VALUE.getRoute(policyID, reportFieldID))}
+ onPress={() => Navigation.navigate(ROUTES.WORKSPACE_REPORT_FIELDS_ADD_VALUE.getRoute(policyID, reportFieldID))}
/>
);
};
@@ -262,7 +262,7 @@ function ReportFieldListValuesPage({
;
+type ReportFieldsSettingsPageProps = WithPolicyAndFullscreenLoadingProps & StackScreenProps;
-function ReportFieldSettingsPage({
+function ReportFieldsSettingsPage({
policy,
route: {
params: {policyID, reportFieldID},
},
-}: ReportFieldSettingsPageProps) {
+}: ReportFieldsSettingsPageProps) {
const styles = useThemeStyles();
const {translate} = useLocalize();
const [isDeleteModalVisible, setIsDeleteModalVisible] = useState(false);
@@ -60,7 +60,7 @@ function ReportFieldSettingsPage({
Navigation.navigate(ROUTES.WORKSPACE_EDIT_REPORT_FIELD_INITIAL_VALUE.getRoute(policyID, reportFieldID))}
+ onPress={() => Navigation.navigate(ROUTES.WORKSPACE_EDIT_REPORT_FIELDS_INITIAL_VALUE.getRoute(policyID, reportFieldID))}
/>
{isListFieldType && (
Navigation.navigate(ROUTES.WORKSPACE_REPORT_FIELD_LIST_VALUES.getRoute(policyID, reportFieldID))}
+ onPress={() => Navigation.navigate(ROUTES.WORKSPACE_REPORT_FIELDS_LIST_VALUES.getRoute(policyID, reportFieldID))}
/>
)}
@@ -121,6 +121,6 @@ function ReportFieldSettingsPage({
);
}
-ReportFieldSettingsPage.displayName = 'ReportFieldSettingsPage';
+ReportFieldsSettingsPage.displayName = 'ReportFieldsSettingsPage';
-export default withPolicyAndFullscreenLoading(ReportFieldSettingsPage);
+export default withPolicyAndFullscreenLoading(ReportFieldsSettingsPage);
diff --git a/src/pages/workspace/reportFields/ReportFieldValueSettingsPage.tsx b/src/pages/workspace/reportFields/ReportFieldsValueSettingsPage.tsx
similarity index 91%
rename from src/pages/workspace/reportFields/ReportFieldValueSettingsPage.tsx
rename to src/pages/workspace/reportFields/ReportFieldsValueSettingsPage.tsx
index f99842fdf5a7..209be2be26b1 100644
--- a/src/pages/workspace/reportFields/ReportFieldValueSettingsPage.tsx
+++ b/src/pages/workspace/reportFields/ReportFieldsValueSettingsPage.tsx
@@ -25,14 +25,14 @@ import ONYXKEYS from '@src/ONYXKEYS';
import ROUTES from '@src/ROUTES';
import type SCREENS from '@src/SCREENS';
-type ReportFieldValueSettingsPageProps = WithPolicyAndFullscreenLoadingProps & StackScreenProps;
+type ReportFieldsValueSettingsPageProps = WithPolicyAndFullscreenLoadingProps & StackScreenProps;
-function ReportFieldValueSettingsPage({
+function ReportFieldsValueSettingsPage({
policy,
route: {
params: {policyID, valueIndex, reportFieldID},
},
-}: ReportFieldValueSettingsPageProps) {
+}: ReportFieldsValueSettingsPageProps) {
const styles = useThemeStyles();
const {translate} = useLocalize();
const [formDraft] = useOnyx(ONYXKEYS.FORMS.WORKSPACE_REPORT_FIELDS_FORM_DRAFT);
@@ -81,7 +81,7 @@ function ReportFieldValueSettingsPage({
};
const navigateToEditValue = () => {
- Navigation.navigate(ROUTES.WORKSPACE_REPORT_FIELD_EDIT_VALUE.getRoute(policyID, valueIndex));
+ Navigation.navigate(ROUTES.WORKSPACE_REPORT_FIELDS_EDIT_VALUE.getRoute(policyID, valueIndex));
};
return (
@@ -93,7 +93,7 @@ function ReportFieldValueSettingsPage({
{
- Navigation.navigate(ROUTES.WORKSPACE_REPORT_FIELD_SETTINGS.getRoute(policyID, reportField.fieldID));
+ const navigateToReportFieldsSettings = (reportField: ReportFieldForList) => {
+ Navigation.navigate(ROUTES.WORKSPACE_REPORT_FIELDS_SETTINGS.getRoute(policyID, reportField.fieldID));
};
const handleDeleteReportFields = () => {
@@ -232,7 +232,7 @@ function WorkspaceReportFieldsPage({
canSelectMultiple
sections={reportFieldsSections}
onCheckboxPress={updateSelectedReportFields}
- onSelectRow={navigateToReportFieldSettings}
+ onSelectRow={navigateToReportFieldsSettings}
onSelectAll={toggleAllReportFields}
ListItem={TableListItem}
customListHeader={getCustomListHeader()}
diff --git a/src/pages/workspace/tags/WorkspaceTagsPage.tsx b/src/pages/workspace/tags/WorkspaceTagsPage.tsx
index 62c0bd104bcb..cf9952720fc9 100644
--- a/src/pages/workspace/tags/WorkspaceTagsPage.tsx
+++ b/src/pages/workspace/tags/WorkspaceTagsPage.tsx
@@ -7,6 +7,7 @@ import Button from '@components/Button';
import ButtonWithDropdownMenu from '@components/ButtonWithDropdownMenu';
import type {DropdownOption} from '@components/ButtonWithDropdownMenu/types';
import ConfirmModal from '@components/ConfirmModal';
+import EmptyStateComponent from '@components/EmptyStateComponent';
import HeaderWithBackButton from '@components/HeaderWithBackButton';
import * as Expensicons from '@components/Icon/Expensicons';
import * as Illustrations from '@components/Icon/Illustrations';
@@ -14,9 +15,9 @@ import ScreenWrapper from '@components/ScreenWrapper';
import SelectionList from '@components/SelectionList';
import ListItemRightCaretWithLabel from '@components/SelectionList/ListItemRightCaretWithLabel';
import TableListItem from '@components/SelectionList/TableListItem';
+import TableListItemSkeleton from '@components/Skeletons/TableRowSkeleton';
import Text from '@components/Text';
import TextLink from '@components/TextLink';
-import WorkspaceEmptyStateSection from '@components/WorkspaceEmptyStateSection';
import useEnvironment from '@hooks/useEnvironment';
import useLocalize from '@hooks/useLocalize';
import useNetwork from '@hooks/useNetwork';
@@ -34,8 +35,9 @@ import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
import ROUTES from '@src/ROUTES';
import type SCREENS from '@src/SCREENS';
+import type {PendingAction} from '@src/types/onyx/OnyxCommon';
import type DeepValueOf from '@src/types/utils/DeepValueOf';
-import type {TagListItem} from './types';
+import type {PolicyTag, PolicyTagList, TagListItem} from './types';
type WorkspaceTagsPageProps = StackScreenProps;
@@ -71,6 +73,15 @@ function WorkspaceTagsPage({route}: WorkspaceTagsPageProps) {
setSelectedTags({});
}, [isFocused]);
+ const getPendingAction = (policyTagList: PolicyTagList): PendingAction | undefined => {
+ if (!policyTagList) {
+ return undefined;
+ }
+ return (policyTagList.pendingAction as PendingAction) ?? Object.values(policyTagList.tags).some((tag: PolicyTag) => tag.pendingAction)
+ ? CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE
+ : undefined;
+ };
+
const tagList = useMemo(() => {
if (isMultiLevelTags) {
return policyTagLists.map((policyTagList) => ({
@@ -79,6 +90,7 @@ function WorkspaceTagsPage({route}: WorkspaceTagsPageProps) {
text: PolicyUtils.getCleanedTagName(policyTagList.name),
keyForList: String(policyTagList.orderWeight),
isSelected: selectedTags[policyTagList.name],
+ pendingAction: getPendingAction(policyTagList),
enabled: true,
required: policyTagList.required,
rightElement: (
@@ -332,9 +344,13 @@ function WorkspaceTagsPage({route}: WorkspaceTagsPageProps) {
/>
)}
{!hasVisibleTag && !isLoading && (
-
)}
diff --git a/src/pages/workspace/tags/types.ts b/src/pages/workspace/tags/types.ts
index b601ce458ce4..cae0e5c4e3dd 100644
--- a/src/pages/workspace/tags/types.ts
+++ b/src/pages/workspace/tags/types.ts
@@ -1,4 +1,5 @@
import type {ListItem} from '@components/SelectionList/types';
+import type {Errors, PendingAction} from '@src/types/onyx/OnyxCommon';
type TagListItem = ListItem & {
value: string;
@@ -6,5 +7,30 @@ type TagListItem = ListItem & {
orderWeight?: number;
};
+type PolicyTag = {
+ name: string;
+ enabled: boolean;
+ previousTagName?: string;
+ /** "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;
+ errors?: Errors | null;
+ rules?: {
+ parentTagsFilter?: string;
+ };
+ parentTagsFilter?: string;
+ pendingAction?: PendingAction | null;
+};
+
+type PolicyTags = Record;
+
+type PolicyTagList = {
+ name: string;
+ orderWeight: number;
+ required: boolean;
+ tags: PolicyTags;
+ pendingAction?: PendingAction | null;
+};
+
// eslint-disable-next-line import/prefer-default-export
-export type {TagListItem};
+export type {TagListItem, PolicyTag, PolicyTagList};
diff --git a/src/styles/index.ts b/src/styles/index.ts
index a0795e5d378a..e422b8bd3d1e 100644
--- a/src/styles/index.ts
+++ b/src/styles/index.ts
@@ -5083,6 +5083,59 @@ const styles = (theme: ThemeColors) =>
fontSize: variables.fontSizeNormal,
fontWeight: FontUtils.fontWeight.bold,
},
+
+ skeletonBackground: {
+ flex: 1,
+ position: 'absolute',
+ top: 0,
+ left: 0,
+ width: '100%',
+ height: '100%',
+ paddingRight: 8,
+ paddingLeft: 8,
+ },
+
+ emptyStateScrollView: {
+ minHeight: 400,
+ height: '100%',
+ flex: 1,
+ },
+
+ emptyStateForeground: (isSmallScreenWidth: boolean) => ({
+ justifyContent: 'center',
+ alignItems: 'center',
+ height: '100%',
+ padding: isSmallScreenWidth ? 32 : 0,
+ width: '100%',
+ }),
+
+ emptyStateContent: {
+ backgroundColor: theme.cardBG,
+ borderRadius: variables.componentBorderRadiusLarge,
+ maxWidth: 400,
+ },
+
+ emptyStateHeader: (isIllustration: boolean) => ({
+ borderTopLeftRadius: variables.componentBorderRadiusLarge,
+ borderTopRightRadius: variables.componentBorderRadiusLarge,
+ minHeight: 200,
+ alignItems: isIllustration ? 'center' : undefined,
+ justifyContent: isIllustration ? 'center' : undefined,
+ }),
+
+ emptyFolderBG: {
+ backgroundColor: theme.emptyFolderBG,
+ },
+
+ emptyStateVideo: {
+ borderTopLeftRadius: variables.componentBorderRadiusLarge,
+ borderTopRightRadius: variables.componentBorderRadiusLarge,
+ },
+
+ emptyStateFolderIconSize: {
+ width: 184,
+ height: 112,
+ },
} satisfies Styles);
type ThemeStyles = ReturnType;
diff --git a/src/styles/theme/themes/dark.ts b/src/styles/theme/themes/dark.ts
index b316f116c805..7ed23c7c0991 100644
--- a/src/styles/theme/themes/dark.ts
+++ b/src/styles/theme/themes/dark.ts
@@ -93,6 +93,7 @@ const darkTheme = {
white: colors.white,
videoPlayerBG: `${colors.productDark100}cc`,
transparentWhite: `${colors.white}51`,
+ emptyFolderBG: colors.yellow600,
// Adding a color here will animate the status bar to the right color when the screen is opened.
// Note that it needs to be a screen name, not a route url.
diff --git a/src/styles/theme/themes/light.ts b/src/styles/theme/themes/light.ts
index 05364515e264..2ebb558ee20b 100644
--- a/src/styles/theme/themes/light.ts
+++ b/src/styles/theme/themes/light.ts
@@ -93,6 +93,7 @@ const lightTheme = {
white: colors.white,
videoPlayerBG: `${colors.productDark100}cc`,
transparentWhite: `${colors.white}51`,
+ emptyFolderBG: colors.yellow600,
// Adding a color here will animate the status bar to the right color when the screen is opened.
// Note that it needs to be a screen name, not a route url.
diff --git a/src/styles/theme/types.ts b/src/styles/theme/types.ts
index 2d8618c24ebb..ffa42e99777d 100644
--- a/src/styles/theme/types.ts
+++ b/src/styles/theme/types.ts
@@ -97,6 +97,7 @@ type ThemeColors = {
white: Color;
videoPlayerBG: Color;
transparentWhite: Color;
+ emptyFolderBG: Color;
PAGE_THEMES: Record;
diff --git a/src/types/form/ReportFieldEditForm.ts b/src/types/form/ReportFieldEditForm.ts
deleted file mode 100644
index 7befa2cb6502..000000000000
--- a/src/types/form/ReportFieldEditForm.ts
+++ /dev/null
@@ -1,6 +0,0 @@
-import type Form from './Form';
-
-type ReportFieldEditForm = Form>;
-
-// eslint-disable-next-line import/prefer-default-export
-export type {ReportFieldEditForm};
diff --git a/src/types/form/ReportFieldsEditForm.ts b/src/types/form/ReportFieldsEditForm.ts
new file mode 100644
index 000000000000..8315490d31c7
--- /dev/null
+++ b/src/types/form/ReportFieldsEditForm.ts
@@ -0,0 +1,6 @@
+import type Form from './Form';
+
+type ReportFieldsEditForm = Form>;
+
+// eslint-disable-next-line import/prefer-default-export
+export type {ReportFieldsEditForm};
diff --git a/src/types/form/index.ts b/src/types/form/index.ts
index dbfc6e5095f6..450de82a60cf 100644
--- a/src/types/form/index.ts
+++ b/src/types/form/index.ts
@@ -25,7 +25,7 @@ export type {PrivateNotesForm} from './PrivateNotesForm';
export type {ProfileSettingsForm} from './ProfileSettingsForm';
export type {ReimbursementAccountForm} from './ReimbursementAccountForm';
export type {ReportDescriptionForm} from './ReportDescriptionForm';
-export type {ReportFieldEditForm} from './ReportFieldEditForm';
+export type {ReportFieldsEditForm} from './ReportFieldsEditForm';
export type {ReportVirtualCardFraudForm} from './ReportVirtualCardFraudForm';
export type {RequestPhysicalCardForm} from './RequestPhysicalCardForm';
export type {RoomNameForm} from './RoomNameForm';
diff --git a/src/types/onyx/SearchResults.ts b/src/types/onyx/SearchResults.ts
index 89d1b6c72566..05cd2db18006 100644
--- a/src/types/onyx/SearchResults.ts
+++ b/src/types/onyx/SearchResults.ts
@@ -1,8 +1,8 @@
import type {ValueOf} from 'type-fest';
+import type {SearchColumnType, SortOrder} from '@components/Search/types';
import type ReportListItem from '@components/SelectionList/Search/ReportListItem';
import type TransactionListItem from '@components/SelectionList/Search/TransactionListItem';
import type {ReportListItemType, TransactionListItemType} from '@components/SelectionList/types';
-import type {SearchColumnType, SortOrder} from '@libs/SearchUtils';
import type CONST from '@src/CONST';
/** Types of search data */
@@ -93,6 +93,9 @@ type SearchPolicyDetails = {
name: string;
};
+/** The action that can be performed for the transaction */
+type SearchTransactionAction = ValueOf;
+
/** Model of report search result */
type SearchReport = {
/** The ID of the report */
@@ -120,7 +123,7 @@ type SearchReport = {
created?: string;
/** The action that can be performed for the report */
- action?: string;
+ action?: SearchTransactionAction;
};
/** Model of transaction search result */
@@ -213,7 +216,7 @@ type SearchTransaction = {
transactionThreadReportID: string;
/** The action that can be performed for the transaction */
- action: string;
+ action: SearchTransactionAction;
/** The MCC Group associated with the transaction */
mccGroup?: ValueOf;
@@ -241,6 +244,9 @@ type SearchResults = {
/** Search results data */
data: Record> & Record & Record;
+
+ /** Whether search data is being fetched from server */
+ isLoading?: boolean;
};
export default SearchResults;
@@ -249,6 +255,7 @@ export type {
SearchQuery,
SearchTransaction,
SearchTransactionType,
+ SearchTransactionAction,
SearchPersonalDetails,
SearchPolicyDetails,
SearchAccountDetails,
diff --git a/tests/e2e/config.ts b/tests/e2e/config.ts
index 8963e07c31c8..9241463f6a0c 100644
--- a/tests/e2e/config.ts
+++ b/tests/e2e/config.ts
@@ -8,6 +8,7 @@ const TEST_NAMES = {
ReportTyping: 'Report typing',
ChatOpening: 'Chat opening',
Linking: 'Linking',
+ PreloadedLinking: 'Preloaded linking',
};
/**
@@ -89,6 +90,7 @@ export default {
// #announce Chat with many messages
reportID: '5421294415618529',
},
+ // linking from chat A to a specific message in chat B
[TEST_NAMES.Linking]: {
name: TEST_NAMES.Linking,
reportScreen: {
@@ -99,6 +101,16 @@ export default {
linkedReportID: '5421294415618529',
linkedReportActionID: '2845024374735019929',
},
+ // linking from chat A to a specific message in the same chat A
+ [TEST_NAMES.PreloadedLinking]: {
+ name: TEST_NAMES.PreloadedLinking,
+ reportScreen: {
+ autoFocus: true,
+ },
+ // Crowded Policy (Do Not Delete) Report, has a input bar available:
+ reportID: '5421294415618529',
+ linkedReportActionID: '8984197495983183608', // Message 4897
+ },
},
};
diff --git a/tests/perf-test/SidebarLinks.perf-test.tsx b/tests/perf-test/SidebarLinks.perf-test.tsx
index 4caa22d062d4..0bd2e83f4e72 100644
--- a/tests/perf-test/SidebarLinks.perf-test.tsx
+++ b/tests/perf-test/SidebarLinks.perf-test.tsx
@@ -20,6 +20,11 @@ jest.mock('../../src/libs/Navigation/Navigation', () => ({
isNavigationReady: jest.fn(() => Promise.resolve()),
isDisplayedInModal: jest.fn(() => false),
}));
+jest.mock('../../src/libs/Navigation/navigationRef', () => ({
+ getState: () => ({
+ routes: [],
+ }),
+}));
jest.mock('@components/Icon/Expensicons');
jest.mock('@react-navigation/native');
diff --git a/tests/ui/UnreadIndicatorsTest.tsx b/tests/ui/UnreadIndicatorsTest.tsx
index b10cae2e7736..ca30eb10b065 100644
--- a/tests/ui/UnreadIndicatorsTest.tsx
+++ b/tests/ui/UnreadIndicatorsTest.tsx
@@ -39,6 +39,10 @@ jest.mock('../../src/components/ConfirmedRoute.tsx');
TestHelper.setupApp();
TestHelper.setupGlobalFetchMock();
+beforeEach(() => {
+ Onyx.set(ONYXKEYS.NVP_ONBOARDING, {hasCompletedGuidedSetupFlow: true});
+});
+
function scrollUpToRevealNewMessagesBadge() {
const hintText = Localize.translateLocal('sidebarScreen.listOfChatMessages');
fireEvent.scroll(screen.getByLabelText(hintText), {
diff --git a/tests/unit/CurrencyUtilsTest.ts b/tests/unit/CurrencyUtilsTest.ts
index 5322faff763f..9a86c9188d42 100644
--- a/tests/unit/CurrencyUtilsTest.ts
+++ b/tests/unit/CurrencyUtilsTest.ts
@@ -155,8 +155,8 @@ describe('CurrencyUtils', () => {
['JPY', 2500.5, '¥25'],
['RSD', 100, 'RSD\xa01.00'],
['RSD', 145, 'RSD\xa01.45'],
- ['BHD', 12345, 'BHD\xa0123.450'],
- ['BHD', 1, 'BHD\xa00.010'],
+ ['BHD', 12345, 'BHD\xa0123.45'],
+ ['BHD', 1, 'BHD\xa00.01'],
])('Correctly displays %s', (currency, amount, expectedResult) => {
expect(CurrencyUtils.convertToDisplayString(amount, currency)).toBe(expectedResult);
});
diff --git a/tests/unit/EmojiTest.ts b/tests/unit/EmojiTest.ts
index 968e543363f2..84442db92553 100644
--- a/tests/unit/EmojiTest.ts
+++ b/tests/unit/EmojiTest.ts
@@ -1,15 +1,7 @@
-import {getUnixTime} from 'date-fns';
-import Onyx from 'react-native-onyx';
import Emojis, {importEmojiLocale} from '@assets/emojis';
import type {Emoji} from '@assets/emojis/types';
-import * as User from '@libs/actions/User';
import {buildEmojisTrie} from '@libs/EmojiTrie';
import * as EmojiUtils from '@libs/EmojiUtils';
-import CONST from '@src/CONST';
-import ONYXKEYS from '@src/ONYXKEYS';
-import type {FrequentlyUsedEmoji} from '@src/types/onyx';
-import * as TestHelper from '../utils/TestHelper';
-import waitForBatchedUpdates from '../utils/waitForBatchedUpdates';
describe('EmojiTest', () => {
beforeAll(async () => {
@@ -196,327 +188,4 @@ describe('EmojiTest', () => {
},
]);
});
-
- describe('update frequently used emojis', () => {
- let spy: jest.SpyInstance;
-
- beforeAll(() => {
- Onyx.init({keys: ONYXKEYS});
- global.fetch = TestHelper.getGlobalFetchMock();
- spy = jest.spyOn(User, 'updateFrequentlyUsedEmojis');
- });
-
- beforeEach(() => {
- spy.mockClear();
- return Onyx.clear();
- });
-
- it('should put a less frequent and recent used emoji behind', () => {
- // Given an existing frequently used emojis list with count > 1
- const frequentlyEmojisList: FrequentlyUsedEmoji[] = [
- {
- code: '👋',
- name: 'wave',
- count: 2,
- lastUpdatedAt: 4,
- types: ['👋🏿', '👋🏾', '👋🏽', '👋🏼', '👋🏻'],
- },
- {
- code: '💤',
- name: 'zzz',
- count: 2,
- lastUpdatedAt: 3,
- },
- {
- code: '💯',
- name: '100',
- count: 2,
- lastUpdatedAt: 2,
- },
- {
- code: '👿',
- name: 'imp',
- count: 2,
- lastUpdatedAt: 1,
- },
- ];
- Onyx.merge(ONYXKEYS.FREQUENTLY_USED_EMOJIS, frequentlyEmojisList);
-
- return waitForBatchedUpdates().then(() => {
- // When add a new emoji
- const currentTime = getUnixTime(new Date());
- const smileEmoji: Emoji = {code: '😄', name: 'smile'};
- const newEmoji = [smileEmoji];
- User.updateFrequentlyUsedEmojis(EmojiUtils.getFrequentlyUsedEmojis(newEmoji));
-
- // Then the new emoji should be at the last item of the list
- const expectedSmileEmoji: FrequentlyUsedEmoji = {...smileEmoji, count: 1, lastUpdatedAt: currentTime};
-
- const expectedFrequentlyEmojisList = [...frequentlyEmojisList, expectedSmileEmoji];
- expect(spy).toBeCalledWith(expectedFrequentlyEmojisList);
- });
- });
-
- it('should put more frequent and recent used emoji to the front', () => {
- // Given an existing frequently used emojis list
- const smileEmoji: Emoji = {code: '😄', name: 'smile'};
- const frequentlyEmojisList: FrequentlyUsedEmoji[] = [
- {
- code: '😠',
- name: 'angry',
- count: 3,
- lastUpdatedAt: 5,
- },
- {
- code: '👋',
- name: 'wave',
- count: 2,
- lastUpdatedAt: 4,
- types: ['👋🏿', '👋🏾', '👋🏽', '👋🏼', '👋🏻'],
- },
- {
- code: '💤',
- name: 'zzz',
- count: 2,
- lastUpdatedAt: 3,
- },
- {
- code: '💯',
- name: '100',
- count: 1,
- lastUpdatedAt: 2,
- },
- {...smileEmoji, count: 1, lastUpdatedAt: 1},
- ];
- Onyx.merge(ONYXKEYS.FREQUENTLY_USED_EMOJIS, frequentlyEmojisList);
-
- return waitForBatchedUpdates().then(() => {
- // When add an emoji that exists in the list
- const currentTime = getUnixTime(new Date());
- const newEmoji = [smileEmoji];
- User.updateFrequentlyUsedEmojis(EmojiUtils.getFrequentlyUsedEmojis(newEmoji));
-
- // Then the count should be increased and put into the very front of the other emoji within the same count
- const expectedFrequentlyEmojisList = [frequentlyEmojisList[0], {...smileEmoji, count: 2, lastUpdatedAt: currentTime}, ...frequentlyEmojisList.slice(1, -1)];
- expect(spy).toBeCalledWith(expectedFrequentlyEmojisList);
- });
- });
-
- it('should sorted descending by count and lastUpdatedAt for multiple emoji added', () => {
- // Given an existing frequently used emojis list
- const smileEmoji: Emoji = {code: '😄', name: 'smile'};
- const zzzEmoji: Emoji = {code: '💤', name: 'zzz'};
- const impEmoji: Emoji = {code: '👿', name: 'imp'};
- const frequentlyEmojisList: FrequentlyUsedEmoji[] = [
- {
- code: '😠',
- name: 'angry',
- count: 3,
- lastUpdatedAt: 5,
- },
- {
- code: '👋',
- name: 'wave',
- count: 2,
- lastUpdatedAt: 4,
- types: ['👋🏿', '👋🏾', '👋🏽', '👋🏼', '👋🏻'],
- },
- {...zzzEmoji, count: 2, lastUpdatedAt: 3},
- {
- code: '💯',
- name: '100',
- count: 1,
- lastUpdatedAt: 2,
- },
- {...smileEmoji, count: 1, lastUpdatedAt: 1},
- ];
- Onyx.merge(ONYXKEYS.FREQUENTLY_USED_EMOJIS, frequentlyEmojisList);
-
- return waitForBatchedUpdates().then(() => {
- // When add multiple emojis that either exist or not exist in the list
- const currentTime = getUnixTime(new Date());
- const newEmoji = [smileEmoji, zzzEmoji, impEmoji];
- User.updateFrequentlyUsedEmojis(EmojiUtils.getFrequentlyUsedEmojis(newEmoji));
-
- // Then the count should be increased for existing emoji and sorted descending by count and lastUpdatedAt
- const expectedFrequentlyEmojisList = [
- {...zzzEmoji, count: 3, lastUpdatedAt: currentTime},
- frequentlyEmojisList[0],
- {...smileEmoji, count: 2, lastUpdatedAt: currentTime},
- frequentlyEmojisList[1],
- {...impEmoji, count: 1, lastUpdatedAt: currentTime},
- frequentlyEmojisList[3],
- ];
- expect(spy).toBeCalledWith(expectedFrequentlyEmojisList);
- });
- });
-
- it('make sure the most recent new emoji is added to the list even it is full with count > 1', () => {
- // Given an existing full (24 items) frequently used emojis list
- const smileEmoji: Emoji = {code: '😄', name: 'smile'};
- const zzzEmoji: Emoji = {code: '💤', name: 'zzz'};
- const impEmoji: Emoji = {code: '👿', name: 'imp'};
- const bookEmoji: Emoji = {code: '📚', name: 'books'};
- const frequentlyEmojisList: FrequentlyUsedEmoji[] = [
- {
- code: '😠',
- name: 'angry',
- count: 3,
- lastUpdatedAt: 24,
- },
- {
- code: '👋',
- name: 'wave',
- count: 3,
- lastUpdatedAt: 23,
- types: ['👋🏿', '👋🏾', '👋🏽', '👋🏼', '👋🏻'],
- },
- {
- code: '😡',
- name: 'rage',
- count: 3,
- lastUpdatedAt: 22,
- },
- {
- code: '😤',
- name: 'triumph',
- count: 3,
- lastUpdatedAt: 21,
- },
- {
- code: '🥱',
- name: 'yawning_face',
- count: 3,
- lastUpdatedAt: 20,
- },
- {
- code: '😫',
- name: 'tired_face',
- count: 3,
- lastUpdatedAt: 19,
- },
- {
- code: '😩',
- name: 'weary',
- count: 3,
- lastUpdatedAt: 18,
- },
- {
- code: '😓',
- name: 'sweat',
- count: 3,
- lastUpdatedAt: 17,
- },
- {
- code: '😞',
- name: 'disappointed',
- count: 3,
- lastUpdatedAt: 16,
- },
- {
- code: '😣',
- name: 'persevere',
- count: 3,
- lastUpdatedAt: 15,
- },
- {
- code: '😖',
- name: 'confounded',
- count: 3,
- lastUpdatedAt: 14,
- },
- {
- code: '👶',
- name: 'baby',
- count: 3,
- lastUpdatedAt: 13,
- types: ['👶🏿', '👶🏾', '👶🏽', '👶🏼', '👶🏻'],
- },
- {
- code: '👄',
- name: 'lips',
- count: 3,
- lastUpdatedAt: 12,
- },
- {
- code: '🐶',
- name: 'dog',
- count: 3,
- lastUpdatedAt: 11,
- },
- {
- code: '🦮',
- name: 'guide_dog',
- count: 3,
- lastUpdatedAt: 10,
- },
- {
- code: '🐱',
- name: 'cat',
- count: 3,
- lastUpdatedAt: 9,
- },
- {
- code: '🐈⬛',
- name: 'black_cat',
- count: 3,
- lastUpdatedAt: 8,
- },
- {
- code: '🕞',
- name: 'clock330',
- count: 3,
- lastUpdatedAt: 7,
- },
- {
- code: '🥎',
- name: 'softball',
- count: 3,
- lastUpdatedAt: 6,
- },
- {
- code: '🏀',
- name: 'basketball',
- count: 3,
- lastUpdatedAt: 5,
- },
- {
- code: '📟',
- name: 'pager',
- count: 3,
- lastUpdatedAt: 4,
- },
- {
- code: '🎬',
- name: 'clapper',
- count: 3,
- lastUpdatedAt: 3,
- },
- {
- code: '📺',
- name: 'tv',
- count: 3,
- lastUpdatedAt: 2,
- },
- {...bookEmoji, count: 3, lastUpdatedAt: 1},
- ];
- expect(frequentlyEmojisList.length).toBe(CONST.EMOJI_FREQUENT_ROW_COUNT * CONST.EMOJI_NUM_PER_ROW);
- Onyx.merge(ONYXKEYS.FREQUENTLY_USED_EMOJIS, frequentlyEmojisList);
-
- return waitForBatchedUpdates().then(() => {
- // When add new emojis
- const currentTime = getUnixTime(new Date());
- const newEmoji = [bookEmoji, smileEmoji, zzzEmoji, impEmoji, smileEmoji];
- User.updateFrequentlyUsedEmojis(EmojiUtils.getFrequentlyUsedEmojis(newEmoji));
-
- // Then the last emojis from the list should be replaced with the most recent new emoji (smile)
- const expectedFrequentlyEmojisList = [
- {...bookEmoji, count: 4, lastUpdatedAt: currentTime},
- ...frequentlyEmojisList.slice(0, -2),
- {...smileEmoji, count: 1, lastUpdatedAt: currentTime},
- ];
- expect(spy).toBeCalledWith(expectedFrequentlyEmojisList);
- });
- });
- });
});
diff --git a/tests/unit/ReportUtilsTest.ts b/tests/unit/ReportUtilsTest.ts
index 4ec530036ba5..aa1a2a60628c 100644
--- a/tests/unit/ReportUtilsTest.ts
+++ b/tests/unit/ReportUtilsTest.ts
@@ -767,8 +767,8 @@ describe('ReportUtils', () => {
});
});
- describe('sortReportsByLastRead', () => {
- it('should filter out report without reportID & lastReadTime and sort lastReadTime in ascending order', () => {
+ describe('getMostRecentlyVisitedReport', () => {
+ it('should filter out report without reportID & lastReadTime and return the most recently visited report', () => {
const reports: Array> = [
{reportID: '1', lastReadTime: '2023-07-08 07:15:44.030'},
{reportID: '2', lastReadTime: undefined},
@@ -778,12 +778,8 @@ describe('ReportUtils', () => {
{reportID: '6'},
undefined,
];
- const sortedReports: Array> = [
- {reportID: '3', lastReadTime: '2023-07-06 07:15:44.030'},
- {reportID: '4', lastReadTime: '2023-07-07 07:15:44.030', type: CONST.REPORT.TYPE.IOU},
- {reportID: '1', lastReadTime: '2023-07-08 07:15:44.030'},
- ];
- expect(ReportUtils.sortReportsByLastRead(reports, undefined)).toEqual(sortedReports);
+ const latestReport: OnyxEntry = {reportID: '1', lastReadTime: '2023-07-08 07:15:44.030'};
+ expect(ReportUtils.getMostRecentlyVisitedReport(reports, undefined)).toEqual(latestReport);
});
});
diff --git a/tests/unit/SidebarFilterTest.ts b/tests/unit/SidebarFilterTest.ts
index c54c2cae54f8..1e0745916717 100644
--- a/tests/unit/SidebarFilterTest.ts
+++ b/tests/unit/SidebarFilterTest.ts
@@ -280,6 +280,35 @@ xdescribe('Sidebar', () => {
);
});
+ it('filter paycheck and bill report', () => {
+ const report1: Report = {
+ ...LHNTestUtils.getFakeReport(),
+ type: CONST.REPORT.TYPE.PAYCHECK,
+ };
+ const report2: Report = {
+ ...LHNTestUtils.getFakeReport(),
+ type: CONST.REPORT.TYPE.BILL,
+ };
+ const report3: Report = LHNTestUtils.getFakeReport();
+ LHNTestUtils.getDefaultRenderedSidebarLinks(report1.reportID);
+ const reportCollectionDataSet: ReportCollectionDataSet = {
+ [`${ONYXKEYS.COLLECTION.REPORT}${report1.reportID}`]: report1,
+ [`${ONYXKEYS.COLLECTION.REPORT}${report2.reportID}`]: report2,
+ [`${ONYXKEYS.COLLECTION.REPORT}${report3.reportID}`]: report3,
+ };
+ return (
+ waitForBatchedUpdates()
+ .then(() => Onyx.multiSet(reportCollectionDataSet))
+
+ // Then the reports 1 and 2 are hidden and 3 is not
+ .then(() => {
+ const hintText = Localize.translateLocal('accessibilityHints.navigatesToChat');
+ const optionRows = screen.queryAllByAccessibilityHint(hintText);
+ expect(optionRows).toHaveLength(1);
+ })
+ );
+ });
+
// NOTE: This is also for #focus mode, should we move this test block?
describe('all combinations of isArchived, isUserCreatedPolicyRoom, hasAddWorkspaceError, isUnread, isPinned, hasDraft', () => {
// Given a report that is the active report and doesn't change
diff --git a/workflow_tests/mocks/deployMocks.ts b/workflow_tests/mocks/deployMocks.ts
index 2fda1efe8e78..5d711b892002 100644
--- a/workflow_tests/mocks/deployMocks.ts
+++ b/workflow_tests/mocks/deployMocks.ts
@@ -10,11 +10,13 @@ const DEPLOY_STAGING__SETUP_GIT__STEP_MOCK = createMockStep('Setup git for OSBot
]);
const DEPLOY_STAGING__TAG_VERSION__STEP_MOCK = createMockStep('Tag version', 'Tagging new version', 'DEPLOY_STAGING');
const DEPLOY_STAGING__PUSH_TAG__STEP_MOCK = createMockStep('🚀 Push tags to trigger staging deploy 🚀', 'Pushing tag to trigger staging deploy', 'DEPLOY_STAGING');
+const DEPLOY_STAGING__WARN_DEPLOYERS__STEP_MOCK = createMockStep('Warn deployers if staging deploy failed', 'Warning deployers in slack for workflow failure', 'DEPLOY_STAGING');
const DEPLOY_STAGING_STEP_MOCKS = [
DEPLOY_STAGING__CHECKOUT__STEP_MOCK,
DEPLOY_STAGING__SETUP_GIT__STEP_MOCK,
DEPLOY_STAGING__TAG_VERSION__STEP_MOCK,
DEPLOY_STAGING__PUSH_TAG__STEP_MOCK,
+ DEPLOY_STAGING__WARN_DEPLOYERS__STEP_MOCK,
] as const satisfies StepIdentifier[];
const DEPLOY_PRODUCTION__CHECKOUT__STEP_MOCK = createMockStep('Checkout', 'Checking out', 'DEPLOY_PRODUCTION', ['ref', 'token']);
@@ -47,6 +49,7 @@ const DEPLOY_PRODUCTION__CREATE_RELEASE__STEP_MOCK = createMockStep(
[],
['GITHUB_TOKEN'],
);
+const DEPLOY_PRODUCTION__WARN_DEPLOYERS__STEP_MOCK = createMockStep('Warn deployers if production deploy failed', 'Warning deployers in slack for workflow failure', 'DEPLOY_STAGING');
const DEPLOY_PRODUCTION_STEP_MOCKS = [
DEPLOY_PRODUCTION__CHECKOUT__STEP_MOCK,
DEPLOY_PRODUCTION__SETUP_GIT__STEP_MOCK,
@@ -54,6 +57,7 @@ const DEPLOY_PRODUCTION_STEP_MOCKS = [
DEPLOY_PRODUCTION__RELEASE_PR_LIST__STEP_MOCK,
DEPLOY_PRODUCTION__GENERATE_RELEASE_BODY__STEP_MOCK,
DEPLOY_PRODUCTION__CREATE_RELEASE__STEP_MOCK,
+ DEPLOY_PRODUCTION__WARN_DEPLOYERS__STEP_MOCK,
] as const satisfies StepIdentifier[];
export default {DEPLOY_STAGING_STEP_MOCKS, DEPLOY_PRODUCTION_STEP_MOCKS};