diff --git a/Mobile-Expensify b/Mobile-Expensify index 4cb8d9fa64d9..096193436135 160000 --- a/Mobile-Expensify +++ b/Mobile-Expensify @@ -1 +1 @@ -Subproject commit 4cb8d9fa64d9cf3b2a1ac099e1f323cb8e1d1be3 +Subproject commit 0961934361353ab8d4b10b7b45d509da523713b9 diff --git a/android/app/build.gradle b/android/app/build.gradle index 15bb82a6541a..9a23db24562d 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -114,8 +114,8 @@ android { minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion multiDexEnabled rootProject.ext.multiDexEnabled - versionCode 1009031000 - versionName "9.3.10-0" + versionCode 1009031003 + versionName "9.3.10-3" // 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/android/app/src/main/java/com/expensify/chat/MainApplication.kt b/android/app/src/main/java/com/expensify/chat/MainApplication.kt index d8bb98d5dc2b..852fdf4a2838 100644 --- a/android/app/src/main/java/com/expensify/chat/MainApplication.kt +++ b/android/app/src/main/java/com/expensify/chat/MainApplication.kt @@ -42,7 +42,7 @@ class MainApplication : MultiDexApplication(), ReactApplication { add(NavBarManagerPackage()) } - override fun getJSMainModuleName() = ".expo/.virtual-metro-entry" + override fun getJSMainModuleName() = "index" override val isNewArchEnabled: Boolean get() = BuildConfig.IS_NEW_ARCHITECTURE_ENABLED diff --git a/assets/images/ExpensifyHelp-WorkspaceSubmissions_Classic.png b/assets/images/ExpensifyHelp-WorkspaceSubmissions_Classic.png new file mode 100644 index 000000000000..9395b4e95b07 Binary files /dev/null and b/assets/images/ExpensifyHelp-WorkspaceSubmissions_Classic.png differ diff --git a/assets/images/ExpensifyHelp-WorkspaceSubmissions_ND.png b/assets/images/ExpensifyHelp-WorkspaceSubmissions_ND.png new file mode 100644 index 000000000000..140640369ab5 Binary files /dev/null and b/assets/images/ExpensifyHelp-WorkspaceSubmissions_ND.png differ diff --git a/assets/images/home.svg b/assets/images/home.svg index 9d9302ab6319..6bc6bf833f2c 100644 --- a/assets/images/home.svg +++ b/assets/images/home.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/babel.config.js b/babel.config.js index f262c5454a7c..374ab6122fda 100644 --- a/babel.config.js +++ b/babel.config.js @@ -65,7 +65,7 @@ const webpack = { }; const metro = { - presets: [require('@react-native/babel-preset')], + presets: [[require('@react-native/babel-preset'), {disableImportExportTransform: true}]], plugins: [ ['babel-plugin-react-compiler', ReactCompilerConfig], // must run first! @@ -177,5 +177,13 @@ module.exports = (api) => { const runningIn = api.caller((args = {}) => args.name); console.debug(' - running in: ', runningIn); + const isJest = runningIn === 'babel-jest'; + if (isJest) { + return { + ...metro, + presets: [[require('@react-native/babel-preset'), {disableImportExportTransform: false}]], + }; + } + return ['metro', 'babel-jest'].includes(runningIn) ? metro : webpack; }; diff --git a/contributingGuides/HOW_TO_BECOME_A_BACKEND_CONTRIBUTOR.md b/contributingGuides/HOW_TO_BECOME_A_BACKEND_CONTRIBUTOR.md new file mode 100644 index 000000000000..5873f25adcf0 --- /dev/null +++ b/contributingGuides/HOW_TO_BECOME_A_BACKEND_CONTRIBUTOR.md @@ -0,0 +1,61 @@ +**Overview** + +We are hiring an exceptionally strong Backend Engineer with deep expertise in C++ and PHP to join a small, senior, fully-remote engineering team. This is a high-trust, high-ownership environment with minimal process, no standups, and no sprint ceremonies. + +This role is intentionally designed for engineers who thrive with autonomy, clear ownership, and written communication. You will be highly visible, highly trusted, and expected to operate independently from day one. + +As part of our high-vetting process, all shortlisted candidates will complete a C++ and PHP technical challenge before moving forward. + +**What You’ll Be Responsible For:** + +- Designing, building, and maintaining backend systems using C++ and PHP as primary technologies +- Owning features and systems end-to-end, from architecture through production delivery +- Solving complex technical problems independently and proactively +- Providing daily written updates on progress, decisions, and blockers +- Actively participating in technical and product discussions via Slack in a chat-driven environment +-Collaborating with a small, senior team in a fully asynchronous operating model + +**Technical Requirements (Non-Negotiable)** + +- Expert-level proficiency in both C++ and PHP +- Clear commitment to C++ and PHP as long-term backend technologies (not stepping stones) +- Demonstrated problem-solving ability, validated through a paid C++ and PHP technical challenge +- Strong backend systems thinking (performance, reliability, maintainability) +- SQL experience is a plus, but not required + +**Communication & Collaboration Expectations** + +- Extremely strong English proficiency, including nuance, tact, and professional judgment +- Comfortable being highly visible and highly active in written discussions +- Able to communicate clearly and concisely in a fully asynchronous, Slack-based environment +- Proactive in surfacing risks, questions, and recommendations without being prompted + +**Work Style & Autonomy** + +- Thrives without traditional management, standups, or sprint structures +- Strong self-management, ownership mentality, and bias toward action +- Able to operate effectively in a flat, non-hierarchical organization +- Comfortable making decisions independently and being accountable for outcomes + +**Time Zone & Availability** + +- Must have at least 6 hours of daily overlap with: US Pacific Time (PT) or Central European Time (CET) +- Time zone overlap is especially critical during the first 3 months for onboarding and alignment + +**Hardware Requirements (Strict)** +- MacBook with Apple Silicon (M1 or newer) +- Minimum 36GB RAM (64GB+ strongly preferred) +- Latest version of macOS installed prior to start date + +**How We Hire** +This role uses a high-vetting process +Selected candidates will complete a C++ and PHP technical challenge +Performance in the challenge will be used to evaluate problem-solving ability, code quality, and communication clarity + +**Skills** + +MySQL, API, SQL, Git, PHP, Microsoft SQL Server, GitHub Copilot, SQLite, Database, C++/CLI, C++, RESTful API, GitHub, JavaScript, Java, PHP Script + +**Next Steps** + +If you're interested in applying, email contributors@expensify.com diff --git a/contributingGuides/NAVIGATION.md b/contributingGuides/NAVIGATION.md index 35b18e8b173b..026c122790e1 100644 --- a/contributingGuides/NAVIGATION.md +++ b/contributingGuides/NAVIGATION.md @@ -64,7 +64,7 @@ import Navigation from '@libs/Navigation/Navigation'; import ROUTES from '@src/ROUTES'; // Basic navigation to a route -Navigation.navigate(ROUTES.HOME); +Navigation.navigate(ROUTES.INBOX); // Navigation with parameters Navigation.navigate( @@ -1344,7 +1344,7 @@ import {ROUTES} from '@src/ROUTES'; Navigation.goBack(); // Back navigation with fallback -Navigation.goBack(ROUTES.HOME); +Navigation.goBack(ROUTES.INBOX); const reportID = 123; // Back navigation to a route with specific params diff --git a/docs/_data/_routes.yml b/docs/_data/_routes.yml index 094e98eaa60a..98bcd4b7ea8d 100644 --- a/docs/_data/_routes.yml +++ b/docs/_data/_routes.yml @@ -113,6 +113,11 @@ platforms: title: Workspaces icon: /assets/images/shield.svg description: Configure rules, settings, and limits for your company’s spending. + + - href: insights + title: Insights + icon: /assets/images/monitor.svg + description: Get insight into company expenses to track spend patterns and stay informed. - href: reports-and-expenses title: Reports & Expenses diff --git a/docs/articles/expensify-classic/reports/Automatically-submit-employee-reports.md b/docs/articles/expensify-classic/reports/Automatically-submit-employee-reports.md index cbc2ae5fbee6..885c75a9e050 100644 --- a/docs/articles/expensify-classic/reports/Automatically-submit-employee-reports.md +++ b/docs/articles/expensify-classic/reports/Automatically-submit-employee-reports.md @@ -1,7 +1,8 @@ --- -title: Automatically Submit Employee Reports -description: Learn how to configure automatic report submissions in Expensify Classic using Submission Frequency in your Workspace settings. -keywords: [Expensify Classic, automatic report submission, delay submissions, submission frequency, expense report schedule] +title: Automatically submit employee reports +description: Learn how to configure automatic employee report submissions in Expensify Classic using the Submissions setting in your Workspace workflows. +internalScope: Audience is Workspace Admins. Covers how to enable and configure automatic employee report submissions in Expensify Classic. Does not cover manual report workflows or approval routing. +keywords: [Expensify Classic, automatic report submission, submission frequency, delay submissions, expense report schedule, Workspace workflows] --- @@ -9,11 +10,26 @@ By setting a submission schedule for your workspace, expenses are automatically --- -# Automatically Submit Employee Reports +# Automatically submit employee reports -When an employee creates an expense, it's automatically added to a report. If no report exists, a new one is created. Reports are then submitted according to the schedule you choose—daily, weekly, monthly, twice per month, by trip, or manually. +When Submissions is enabled, employee expenses are automatically added to a report and submitted for approval based on the schedule you choose. This saves your team from submitting reports manually. -**Note:** If you're using **Submission Frequency** and an expense has a violation, it won't be submitted until the violation is fixed. That expense is removed from the current report and added to a new open report. +If there’s no open report, a new one is created automatically. Reports are submitted according to your selected frequency - daily, weekly, twice per month, by trip, instantly, or manually. + +**Note:** Expenses with violations (like missing receipts or incorrect categories) won’t be submitted. They’re removed from the report and moved to a new open report. Once the violation is fixed, they'll be submitted on the next scheduled date. + +--- + +# Who can enable automatic report submissions + +Only **Workspace Admins** can turn on Submissions and choose a submission frequency for their Workspace. + +--- + +# Where to find the Submissions setting + +- **Web:** Go to the navigation tabs on the left and select **Workspaces > [Workspace Name] > Workflows** +- **Mobile:** Tap the hamburger menu in the top-left corner, then select **Workspaces > [Workspace Name] > Workflows** --- @@ -22,37 +38,62 @@ When an employee creates an expense, it's automatically added to a report. If no To enable and configure automatic submissions: 1. Go to **Settings > Workspace > [Workspace Name] > Workflows**. -2. Turn on **Submission Frequency** by toggling it on. -3. Select a **Submission frequency** from the following options: - - **Instantly** - Expenses are submitted upon creation. - - **Daily** – Reports are submitted every evening. Violations are submitted once corrected. - - **Weekly** – Reports are submitted weekly. Violations are submitted on Sunday after correction. - - **Twice a month** – Reports are submitted on the 15th and the last day of the month. Violations are submitted at the next applicable date. - - **Monthly** – Reports are submitted once a month on your selected day. Violations are submitted the following month. - - **By trip** – A report is submitted when no new expenses are added for two full days. A new trip report starts after that. - - **Manually** – Expenses are auto-added to a report, but employees must submit them manually. +2. Find the **Submissions** section +3. Toggle it on (green) +4. Choose **how often expenses submit**: + - **Instantly** — Expenses are submitted as soon as they’re created + - **Daily** — Reports are submitted each evening (Pacific Time) + - **Weekly** — Reports are submitted weekly on Sundays + - **Twice a month** — Reports are submitted on the 15th and last day of the month + - **By trip** — A report is submitted after two full days without new expenses + - **Manually** — Expenses are auto-added to reports, but members must submit manually + +![Submissions section in Expensify Workflows settings, showing the toggle enabled and Frequency set to By trip]({{site.url}}/assets/images/ExpensifyHelp-WorkspaceSubmissions_Classic.png){:width="100%"} + +--- + +## How to use the By trip submission frequency + +If your team travels often, choose **By trip** as your submission frequency. A report is submitted after two full days without any new expenses. A new trip report begins after that. + +That way, travel expenses are grouped together without needing to sort them manually. --- # FAQ -## I turned off Submission Frequency. Why are reports still being submitted automatically? +## What happens to expenses with violations? + +Expenses with violations—like missing receipts, incorrect categories, or amounts over Workspace limits—won’t be submitted. They’re removed from the current report and moved to a new open report. Once the violation is fixed, they’ll be submitted on the next scheduled date. + +## Why are reports still submitting automatically after disabling Submissions? + +If Submissions is disabled or set to **Manually** on the Workspace, check the member’s **Individual Workspace**. Their personal settings may still have Submissions enabled, which will apply by default. + +## What time of day does automatic submission happen? + +All scheduled report submissions occur in the **evening (Pacific Time)**, regardless of which frequency you choose. + +## Can I create separate automatic reports for each employee credit card? + +No. Expenses from multiple cards are combined into a single report, based on the selected frequency. -Turning off Submission Frequency for a Workspace doesn't affect an employee's Workspace settings. If reports are still auto-submitted, the employee will likely have Submission Frequency enabled in their workspace. +To separate them: +- Manually create reports for each card +- Filter by card and assign expenses to the correct report -## What time of day are reports submitted via Submission Frequency? +## Can employees override the Workspace submission schedule? -All automatic report submissions occur in the evening Pacific Standard Time (PST). +No. Once Submissions is enabled on the Workspace, it overrides any individual submission settings members may have in their own Workspaces. -## What happens if Submission Frequency is enabled on both the Individual and Company Workspace? +## Can automatic submissions be paused during month-end review? -The Company Workspace settings override the Individual Workspace settings. However, suppose your Company Workspace is configured to **Manually** submit reports, but an employee has Submission Frequency enabled on their **Individual Workspace** with a set frequency (like daily or weekly. In that case, their personal settings will control submission timing. Reports will be submitted automatically based on the frequency selected in their workspace. +There’s no pause button, but you can temporarily change the Frequency setting to **Manually** to prevent automatic submissions. Just switch it back after your review period. -## Does Submission Frequency automatically create separate reports for each of my credit cards? +## Can employees still submit manually if Submissions is turned on? -No. All expenses are collected into a single report and submitted based on the selected frequency. +Yes. Employees can submit reports manually at any time—even if a scheduled frequency (like Daily or By trip) is selected. The schedule just automates submission if they don’t act first. -If you need reports separated by card: -- Manually create reports for each card and assign expenses accordingly. -- Use filters to group expenses by card before assigning them to reports. +## Do scheduled submissions include all unsubmitted expenses? +Only compliant expenses will be submitted. Expenses with violations, missing data, or issues will stay in an open report until fixed. diff --git a/docs/articles/new-expensify/billing-and-subscriptions/Add-a-payment-card-and-view-your-subscription.md b/docs/articles/new-expensify/billing-and-subscriptions/Add-a-payment-card-and-view-your-subscription.md index 1fb1c8aa94a5..b9634c1deb13 100644 --- a/docs/articles/new-expensify/billing-and-subscriptions/Add-a-payment-card-and-view-your-subscription.md +++ b/docs/articles/new-expensify/billing-and-subscriptions/Add-a-payment-card-and-view-your-subscription.md @@ -1,20 +1,59 @@ --- title: Manage Expensify Subscription description: Learn how to manage your subscription, update billing details, cancel early, or delete a workspace in New Expensify. -keywords: [New Expensify, subscription, billing, payment card, cancel subscription, delete workspace, remove workspace, subscription settings, bill] +keywords: [New Expensify, billing owner, manage subscription, update payment card, cancel subscription, delete workspace, billing and subscriptions, subscription settings, can't delete workspace, can't cancel subscription] +internalScope: Audience is billing owners and Workspace Admins. Covers how to manage a paid subscription in New Expensify, including billing permissions and ownership transfer. Does not cover free workspaces or plan upgrades. +--- + +You can manage your Expensify subscription anytime from your account settings—update your billing info, request early cancellation, or delete a workspace if you're the billing owner. + +View and manage your subscription under **Account > Subscription**. + +--- + +# Who can manage billing and subscription actions in New Expensify + +Only the **billing owner** can: + +- Add or change the payment card +- Request early cancellation of an annual plan +- Delete a paid workspace + +If you don’t see these options, you’re likely not the billing owner. + +To confirm who the billing owner is: + +1. Go to the Workspaces tab (left side on web, bottom tab on mobile). +2. Look for the **Billing owner** column for the relevant workspace. + +You’ll need to be listed as the billing owner to make subscription changes. --- +# Where to access subscription settings -Easily manage your subscription in New Expensify—update payment details, adjust your plan, request early cancellation, or delete a workspace if needed. +To manage your subscription: -You can view and manage your subscription under **Account > Subscription**. +- **On web:** Click your profile icon, then go to **Account > Subscription** in the left-hand navigation tabs. +- **On mobile:** Tap the **Account** tab at the bottom, then select **Subscription**. --- -# Manage Subscription +# How to update your Expensify billing card -In the Subscription section, you can view and update your plan details, including: +If you're the billing owner, to add or change your Expensify billing card: + +1. Go to **Account > Subscription**. +2. Click **Add a payment card** or **Edit** next to your existing card. +3. Enter your new card details and click **Save**. + +If you don’t see these options, you’re likely not the billing owner. + +--- + +# How to manage your subscription details + +You can view and update your plan details from the **Subscription** section: - **Add a payment card**: Connect the credit card you want to use to pay for Expensify each month. - **Current plan:** View your subscription type and number of seats. @@ -24,34 +63,82 @@ In the Subscription section, you can view and update your plan details, includin **Note:** Enabling auto-increase will extend your annual subscription end date. +To access the **Subscription** section: + +- **On web:** Click your profile icon, then go to **Account > Subscription **in the navigation tabs on the left. +- **On mobile:** Tap the **Account** tab at the bottom, then select **Subscription**. + + +--- + +# How to request early cancellation of an annual subscription + +1. Go to **Account > Subscription**. +2. Click **Request early cancellation**. +3. Follow the prompts. Your request may be processed automatically or reviewed by our team. + +**Note:** This option is only available to the billing owner and early cancellation is not available to all members. + +--- + +# How to stop billing for a paid workspace + +To stop being charged for Expensify, you’ll need to either: + +- **Delete the paid workspace**, or +- **Transfer billing ownership** to another member. + +**Note:** If you're on an annual subscription, you can’t delete your last workspace unless you’ve first requested and received early cancellation. + --- -# Request Early Cancellation +## How to delete a workspace in Expensify -To request an early cancellation: +Only the **billing owner** can delete a workspace. -1. Head to **Account > Subscription**. -2. Click the **Request early cancellation** button. -3. Depending on how your subscription is configured, the early cancellation will happen automatically or be sent to our team for review. +1. On web, go to the **Workspaces** tab in the left navigation. + On mobile, tap the **Workspaces** tab at the bottom. +2. Find the workspace you want to delete. +3. Click the three dots next to it and select **Delete workspace**. +4. Confirm when prompted. -**Note:** Early cancellations aren’t available to all customers. +**Note:** This action is permanent and cannot be undone. + +**ADD A SCREENSHOT HERE.** Suggestion: Deleting a workspace via the three-dot menu in the Workspaces tab. --- -# Delete a Workspace +## How to transfer billing ownership in Expensify + +To transfer billing responsibility to another **Workspace Admin**, ask that member to [follow these steps to transfer billing ownership](LINK). + +Only Workspace Admins can transfer billing ownership to themselves - it’s not possible for someone else to transfer ownership to them. -To permanently delete a workspace and downgrade to a free account: +--- + +# What to do if you can’t update a subscription + +If you're blocked from updating billing settings, canceling early, or deleting a workspace, it's likely because you’re not the billing owner. + +If you are a Workspace Admin, you can transfer billing ownership to yourself by following the steps to [transfer billing ownership](LINK). + +--- + +# FAQ + +## Why don’t I see the option to cancel or delete my workspace? + +Only the billing owner can cancel a subscription or delete a workspace. To confirm if you're the billing owner, go to the **Workspaces** tab and check the **Billing owner** column for the relevant workspace. + +--- -1. From the navigation tabs (on the left on web, and at the bottom on mobile), click **Workspaces**. -2. Click the three dots next to the workspace name. -3. Select **Delete workspace** from the dropdown. -4. Confirm the deletion when prompted. +## Can I delete a workspace if I’m on an annual subscription? -**Note:** Deleting a workspace is permanent and cannot be undone. +Only if you’ve been granted early cancellation. Otherwise, you’ll need to either wait for the subscription to end or request early cancellation first. --- -# Pricing Information +## Will deleting a workspace stop all billing? -Visit our [Billing page](https://help.expensify.com/new-expensify/hubs/billing-and-subscriptions/) for full pricing details. +No — billing continues as long as you own any paid workspace. To fully stop billing, you’ll need to delete or transfer **all** paid workspaces associated with your account. diff --git a/docs/articles/new-expensify/insights/View-the-Top-Spenders-report.md b/docs/articles/new-expensify/insights/View-the-Top-Spenders-report.md new file mode 100644 index 000000000000..5ea5264b4d75 --- /dev/null +++ b/docs/articles/new-expensify/insights/View-the-Top-Spenders-report.md @@ -0,0 +1,110 @@ +--- +title: View the Top Spenders report +description: Learn how Workspace Admins, Approvers, and Auditors can use the Top Spenders report to understand spending trends. +keywords: New Expensify, Top Spenders, employee spending, high spenders, expense trends, Workspace Admin, Approver, Auditor, monthly spending, spending insights, virtual CFO, analytics, insight, budget +internalScope: Audience is Workspace Admins, Approvers, and Auditors. Covers using the Top Spenders suggested search to view employee-level spending. Does not cover custom reports, exporting data, or grouping by category or merchant. +--- + +# View the Top Spenders report in New Expensify + +The **Top Spenders** report shows which employees submitted the highest total expenses last month. It’s a fast way to: + +- Identify high or unusual spenders +- Spot trends without exporting data +- Make data-backed decisions with minimal effort + +This report is a pre-built suggested search so you can review spending activity at a glance. + +--- + +## Who can use the Top Spenders report + +- **Available to:** Workspace Admins, Approvers, and Auditors +- **Platforms:** + - **Web:** Full view + - **Mobile:** Read-only + +--- + +## Where to find the Top Spenders report + +**Web:** +Use the navigation tabs on the left and select **Top spenders** under the **Reports** section. + +**Mobile:** +Tap the hamburger menu in the top-left corner, then select **Top spenders**. + +![Reports page in New Expensify showing the Top spenders view]({{site.url}}/assets/images/top-spender.png) + +--- + +## What information the Top Spenders report displays + +The Top Spenders report is powered by a saved search query using Expensify's grouping and filtering engine. The report shows: + +- The **top expense submitters** from last month +- The **total amount spent** by each person +- The **number of expenses** by each person +--- + +## How to interpret Top Spenders report data + +Each row represents an individual employee, sorted in descending order by amount. Columns include: + +- **Number of expenses submitted** +- **Total amount spent** + +Click any row to view that member's individual expenses. + +--- + +## Can you customize the Top Spenders report? + +Yes - you can adjust filters like date range, workspace, or employee to explore spending trends. However, the **Top Spenders** report is a built-in suggested search, so you can’t save changes to it directly. + +To create and save a custom report: + +1. Go to the **Reports** or **Expenses** tab. +2. Use filters to adjust grouping and timeframes. +3. Apply filters and save your custom search. + +[Learn how to create custom reports](https://help.expensify.com/articles/new-expensify/reports-and-expenses/Using-Reports-in-New-Expensify#How-to-use-Reports-search-query-commands). + +--- + +## Ways to use the Top Spenders report + +Workspace Admins and finance teams use the Top Spenders report to: + +- Identify high or unusual spenders at a glance +- Spot spending trends without exporting data +- Make data-backed decisions quickly + +--- + +# FAQ + +## Can you export the Top Spenders report? + +Not directly — the Top Spenders report can’t be exported with its grouped totals or summary data. However, if you expand each group to reveal the individual expenses, you can then select those expenses and use Export to CSV to download the raw data. + +1. Go to the **Reports** tab. +2. Apply filters to create your own view. +3. Click **Export to CSV**. + +## Will more report types like Top Spenders be added? + +Yes! Top Spenders is the first of many suggested insights. Upcoming reports will include: + +- Spend by category +- Spend over time +- Merchant trends +- Other insights visible under the **Reports** tab + +## Can other people see my Top Spenders report? + +Only Workspace Admins, Approvers, and Auditors can view a workspace's Top Spenders report. Regular members do not have access. + +## How is the Top Spenders report calculated? + +The Top Spenders report uses expenses from the previous calendar month and groups them by submitter (employee). It shows the top 10 people by total amount spent. diff --git a/docs/articles/new-expensify/reports-and-expenses/How-to-Set-Up-Automatic-Report-Submissions.md b/docs/articles/new-expensify/reports-and-expenses/How-to-Set-Up-Automatic-Report-Submissions.md new file mode 100644 index 000000000000..e8f56f4d816e --- /dev/null +++ b/docs/articles/new-expensify/reports-and-expenses/How-to-Set-Up-Automatic-Report-Submissions.md @@ -0,0 +1,94 @@ +--- +title: How to set up automatic employee report submissions +description: Enable Submissions to automatically submit employee expenses on a custom schedule—daily, weekly, by trip, and more. +keywords: [submit reports automatically, expense submission schedule, Submissions toggle, report frequency, Workspace Admin, expense automation, submit by trip] +internalScope: Audience is Workspace Admins. Covers how to enable and configure automatic report submissions using the Submissions setting. Does not cover manual report workflows or approval routing. +--- + +# How to set up automatic employee report submissions + +When Submissions is enabled, employee expenses are automatically added to a report and submitted for approval based on the schedule you choose. This saves your team from submitting reports manually. + +If there’s no open report, a new one is created automatically. Reports are submitted according to your selected frequency—daily, weekly, twice per month, by trip, instantly, or manually. + +**Note:** Expenses with violations (like missing receipts or categories) won’t be submitted. See the FAQ below for how those expenses are handled. + +--- + +# Who can enable automatic report submissions + +Only **Workspace Admins** can turn on Submissions and choose a submission frequency for their Workspace. + +--- + +# Where to find the Submissions setting + +- **Web:** Go to the navigation tabs on the left and select **Workspaces > [Workspace Name] > Workflows** +- **Mobile:** Tap the hamburger menu in the top-left corner, then select **Workspaces > [Workspace Name] > Workflows** + +--- + +# How to enable Submissions and choose a schedule + +1. Go to **Workspaces > [Workspace Name] > Workflows** +2. Find the **Submissions** section +3. Toggle it on (green) +4. Select your preferred **Frequency**: + - **Instantly** — Expenses are submitted as soon as they’re created + - **Daily** — Reports are submitted each evening (Pacific Time) + - **Weekly** — Reports are submitted weekly on Sundays + - **Twice a month** — Reports are submitted on the 15th and last day of the month + - **By trip** — A report is submitted after two full days without new expenses + - **Manually** — Expenses are auto-added to reports, but members must submit manually + +![Submissions section in Expensify Workflows settings, showing the toggle enabled and Frequency set to By trip]({{site.url}}/assets/images/ExpensifyHelp-WorkspaceSubmissions_ND.png){:width="100%"} + +assets/images/ExpensifyHelp-WorkspaceSubmissions_Classic.png +--- + +## How to use the By trip submission frequency + +If your team travels often, choose **By trip** as your submission frequency. A report is submitted after two full days without any new expenses. A new trip report begins after that. + +That way, travel expenses are grouped together without needing to sort them manually. + +--- + +# FAQ + +## What happens to expenses with violations? + +Expenses with violations—like missing receipts, incorrect categories, or amounts over Workspace limits—won’t be submitted. They’re removed from the current report and moved to a new open report. Once the violation is fixed, they’ll be submitted on the next scheduled date. + +## Why are reports still submitting automatically after disabling Submissions + +If Submissions is disabled or set to **Manually** on the Workspace, check the member’s **Individual Workspace**. Their personal settings may still have Submissions enabled, which will apply by default. + +## What time of day does automatic submission happen? + +All scheduled report submissions occur in the **evening (Pacific Time)**, regardless of which frequency you choose. + +## Can separate reports be created automatically for each employee credit card? + +No. Expenses from multiple cards are combined into a single report, based on the selected frequency. + +To separate them: +- Manually create reports for each card +- Filter by card and assign expenses to the correct report + +## Can employees override the Workspace submission schedule? + +No. Once Submissions is enabled on the Workspace, it overrides any individual submission settings members may have in their own Workspaces. + +## Can automatic submissions be paused during month-end review? + +There’s no pause button, but you can temporarily change the Frequency setting to **Manually** to prevent automatic submissions. Just switch it back after your review period. + +## Can employees still submit manually if Submissions is turned on? + +Yes. Employees can submit reports manually at any time—even if a scheduled frequency (like Daily or By trip) is selected. The schedule just automates submission if they don’t act first. + +## Do scheduled submissions include all unsubmitted expenses? + +Only compliant expenses will be submitted. Expenses with violations, missing data, or issues will stay in an open report until fixed. + diff --git a/docs/assets/images/top-spender.png b/docs/assets/images/top-spender.png new file mode 100644 index 000000000000..00cceddcf1c0 Binary files /dev/null and b/docs/assets/images/top-spender.png differ diff --git a/docs/new-expensify/hubs/insights/index.html b/docs/new-expensify/hubs/insights/index.html new file mode 100644 index 000000000000..22e23e161cd3 --- /dev/null +++ b/docs/new-expensify/hubs/insights/index.html @@ -0,0 +1,6 @@ +--- +layout: default +title: Insights +--- + +{% include hub.html %} diff --git a/ios/NewExpensify/Info.plist b/ios/NewExpensify/Info.plist index bc8f3de9fae8..57a7167ba558 100644 --- a/ios/NewExpensify/Info.plist +++ b/ios/NewExpensify/Info.plist @@ -44,7 +44,7 @@ CFBundleVersion - 9.3.10.0 + 9.3.10.3 FullStory OrgId diff --git a/ios/NotificationServiceExtension/Info.plist b/ios/NotificationServiceExtension/Info.plist index 058b2db16a7e..200918ea5cd0 100644 --- a/ios/NotificationServiceExtension/Info.plist +++ b/ios/NotificationServiceExtension/Info.plist @@ -13,7 +13,7 @@ CFBundleShortVersionString 9.3.10 CFBundleVersion - 9.3.10.0 + 9.3.10.3 NSExtension NSExtensionPointIdentifier diff --git a/ios/ShareViewController/Info.plist b/ios/ShareViewController/Info.plist index 346cbad10467..33798a26ef59 100644 --- a/ios/ShareViewController/Info.plist +++ b/ios/ShareViewController/Info.plist @@ -13,7 +13,7 @@ CFBundleShortVersionString 9.3.10 CFBundleVersion - 9.3.10.0 + 9.3.10.3 NSExtension NSExtensionAttributes diff --git a/metro.config.js b/metro.config.js index 59479c07b374..ab54c066f6bd 100644 --- a/metro.config.js +++ b/metro.config.js @@ -1,4 +1,3 @@ -const {getDefaultConfig: getExpoDefaultConfig} = require('expo/metro-config'); const {getDefaultConfig: getReactNativeDefaultConfig} = require('@react-native/metro-config'); const {mergeConfig} = require('@react-native/metro-config'); @@ -13,7 +12,6 @@ const envPath = process.env.ENVFILE ? (path.isAbsolute(process.env.ENVFILE) ? pr require('dotenv').config({path: envPath}); const defaultConfig = getReactNativeDefaultConfig(__dirname); -const expoConfig = getExpoDefaultConfig(__dirname); const isE2ETesting = process.env.E2E_TESTING === 'true'; const e2eSourceExts = ['e2e.js', 'e2e.ts', 'e2e.tsx']; @@ -37,6 +35,7 @@ const config = { transformer: { getTransformOptions: async () => ({ transform: { + experimentalImportSupport: true, inlineRequires: true, }, }), @@ -48,6 +47,6 @@ const config = { : {}, }; -const mergedConfig = wrapWithReanimatedMetroConfig(mergeConfig(defaultConfig, expoConfig, config)); +const mergedConfig = wrapWithReanimatedMetroConfig(mergeConfig(defaultConfig, config)); module.exports = isDev ? mergedConfig : withSentryConfig(mergedConfig); diff --git a/package-lock.json b/package-lock.json index 50c7319f5e3b..8850ce524651 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "new.expensify", - "version": "9.3.10-0", + "version": "9.3.10-3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "new.expensify", - "version": "9.3.10-0", + "version": "9.3.10-3", "hasInstallScript": true, "license": "MIT", "dependencies": { @@ -121,7 +121,7 @@ "react-native-localize": "^3.5.4", "react-native-nitro-modules": "0.29.4", "react-native-nitro-sqlite": "9.2.0", - "react-native-onyx": "3.0.31", + "react-native-onyx": "3.0.32", "react-native-pager-view": "7.0.2", "react-native-pdf": "7.0.2", "react-native-performance": "^6.0.0", @@ -34164,9 +34164,9 @@ } }, "node_modules/react-native-onyx": { - "version": "3.0.31", - "resolved": "https://registry.npmjs.org/react-native-onyx/-/react-native-onyx-3.0.31.tgz", - "integrity": "sha512-QWAuJIjVjOycobk1eUsvRB0SgBTnHOowHPxQTDtC53ayaxoeN/f10ziKIe6on7VEKzzP7GWN3CJuBwR4Uqbijg==", + "version": "3.0.32", + "resolved": "https://registry.npmjs.org/react-native-onyx/-/react-native-onyx-3.0.32.tgz", + "integrity": "sha512-DIZ37AVnKvhEctc90RepcDsJChqx4Gi2LLwvbrBouK0IZ9zrs7CeGoq/wtRXyuvaVYlLV5bFBTUNP9/jKnauyA==", "license": "MIT", "dependencies": { "ascii-table": "0.0.9", diff --git a/package.json b/package.json index 8efda9ac47ee..d4d851ba0a3c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "new.expensify", - "version": "9.3.10-0", + "version": "9.3.10-3", "author": "Expensify, Inc.", "homepage": "https://new.expensify.com", "description": "New Expensify is the next generation of Expensify: a reimagination of payments based atop a foundation of chat.", @@ -190,7 +190,7 @@ "react-native-localize": "^3.5.4", "react-native-nitro-modules": "0.29.4", "react-native-nitro-sqlite": "9.2.0", - "react-native-onyx": "3.0.31", + "react-native-onyx": "3.0.32", "react-native-pager-view": "7.0.2", "react-native-pdf": "7.0.2", "react-native-performance": "^6.0.0", diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 84dd869353cb..22e530eaaa26 100755 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -743,6 +743,7 @@ const CONST = { NEW_DOT_DEW: 'newDotDEW', GPS_MILEAGE: 'gpsMileage', NEW_DOT_HOME: 'newDotHome', + PERSONAL_CARD_IMPORT: 'personalCardImport', }, BUTTON_STATES: { DEFAULT: 'default', @@ -1708,7 +1709,12 @@ const CONST = { }, TELEMETRY: { CONTEXT_FULLSTORY: 'Fullstory', + CONTEXT_MEMORY: 'Memory', CONTEXT_POLICIES: 'Policies', + // Breadcrumb names + BREADCRUMB_CATEGORY_MEMORY: 'system.memory', + BREADCRUMB_MEMORY_PERIODIC: 'Periodic memory check', + BREADCRUMB_MEMORY_FOREGROUND: 'App foreground - memory check', TAG_ACTIVE_POLICY: 'active_policy_id', TAG_NUDGE_MIGRATION_COHORT: 'nudge_migration_cohort', TAG_AUTHENTICATION_FUNCTION: 'authentication_function', @@ -1765,6 +1771,11 @@ const CONST = { ATTRIBUTE_FINISHED_MANUALLY: 'finished_manually', CONFIG: { SKELETON_MIN_DURATION: 10_000, + MEMORY_THRESHOLD_CRITICAL_PERCENTAGE: 90, + MEMORY_TRACKING_INTERVAL: 2 * 60 * 1000, + // Memory Thresholds (in MB) + MEMORY_THRESHOLD_WARNING: 120, + MEMORY_THRESHOLD_CRITICAL: 50, }, }, PRIORITY_MODE: { @@ -3758,6 +3769,18 @@ const CONST = { DELETE: 'delete', }, }, + MERCHANT_RULES: { + FIELDS: { + BILLABLE: 'billable', + CATEGORY: 'category', + DESCRIPTION: 'comment', + MERCHANT_TO_MATCH: 'merchantToMatch', + MERCHANT: 'merchant', + REIMBURSABLE: 'reimbursable', + TAG: 'tag', + TAX: 'tax', + }, + }, get SUBSCRIPTION_PRICES() { return { @@ -6859,6 +6882,8 @@ const CONST = { CARD: 'card', WITHDRAWAL_ID: 'withdrawal-id', CATEGORY: 'category', + TAG: 'tag', + MONTH: 'month', }, get TYPE_CUSTOM_COLUMNS() { return { @@ -6940,6 +6965,16 @@ const CONST = { EXPENSES: this.TABLE_COLUMNS.GROUP_EXPENSES, TOTAL: this.TABLE_COLUMNS.GROUP_TOTAL, }, + TAG: { + TAG: this.TABLE_COLUMNS.GROUP_TAG, + EXPENSES: this.TABLE_COLUMNS.GROUP_EXPENSES, + TOTAL: this.TABLE_COLUMNS.GROUP_TOTAL, + }, + MONTH: { + MONTH: this.TABLE_COLUMNS.GROUP_MONTH, + EXPENSES: this.TABLE_COLUMNS.GROUP_EXPENSES, + TOTAL: this.TABLE_COLUMNS.GROUP_TOTAL, + }, }; }, get TYPE_DEFAULT_COLUMNS() { @@ -6982,6 +7017,8 @@ const CONST = { this.TABLE_COLUMNS.GROUP_TOTAL, ], CATEGORY: [this.TABLE_COLUMNS.GROUP_CATEGORY, this.TABLE_COLUMNS.GROUP_EXPENSES, this.TABLE_COLUMNS.GROUP_TOTAL], + TAG: [this.TABLE_COLUMNS.GROUP_TAG, this.TABLE_COLUMNS.GROUP_EXPENSES, this.TABLE_COLUMNS.GROUP_TOTAL], + MONTH: [this.TABLE_COLUMNS.GROUP_MONTH, this.TABLE_COLUMNS.GROUP_EXPENSES, this.TABLE_COLUMNS.GROUP_TOTAL], }; }, BOOLEAN: { @@ -7078,6 +7115,8 @@ const CONST = { GROUP_WITHDRAWN: 'groupWithdrawn', GROUP_WITHDRAWAL_ID: 'groupWithdrawalID', GROUP_CATEGORY: 'groupCategory', + GROUP_TAG: 'groupTag', + GROUP_MONTH: 'groupmonth', }, SYNTAX_OPERATORS: { AND: 'and', @@ -7266,6 +7305,7 @@ const CONST = { [this.TABLE_COLUMNS.GROUP_WITHDRAWN]: 'group-withdrawn', [this.TABLE_COLUMNS.GROUP_WITHDRAWAL_ID]: 'group-withdrawal-id', [this.TABLE_COLUMNS.GROUP_CATEGORY]: 'group-category', + [this.TABLE_COLUMNS.GROUP_TAG]: 'group-tag', }; }, NOT_MODIFIER: 'Not', @@ -7283,6 +7323,7 @@ const CONST = { NEVER: 'never', LAST_MONTH: 'last-month', THIS_MONTH: 'this-month', + YEAR_TO_DATE: 'year-to-date', LAST_STATEMENT: 'last-statement', }, SNAPSHOT_ONYX_KEYS: [ @@ -7925,6 +7966,7 @@ const CONST = { REPORTS: 'NavigationTabBar-Reports', WORKSPACES: 'NavigationTabBar-Workspaces', ACCOUNT: 'NavigationTabBar-Account', + HOME: 'NavigationTabBar-Home', FLOATING_ACTION_BUTTON: 'NavigationTabBar-FloatingActionButton', FLOATING_RECEIPT_BUTTON: 'NavigationTabBar-FloatingReceiptButton', }, diff --git a/src/Expensify.tsx b/src/Expensify.tsx index b3947c1f3aba..be2d83ad55a1 100644 --- a/src/Expensify.tsx +++ b/src/Expensify.tsx @@ -55,8 +55,8 @@ import './libs/telemetry/TelemetrySynchronizer'; import './libs/UnreadIndicatorUpdater'; import Visibility from './libs/Visibility'; import ONYXKEYS from './ONYXKEYS'; -import PopoverReportActionContextMenu from './pages/home/report/ContextMenu/PopoverReportActionContextMenu'; -import * as ReportActionContextMenu from './pages/home/report/ContextMenu/ReportActionContextMenu'; +import PopoverReportActionContextMenu from './pages/inbox/report/ContextMenu/PopoverReportActionContextMenu'; +import * as ReportActionContextMenu from './pages/inbox/report/ContextMenu/ReportActionContextMenu'; import type {Route} from './ROUTES'; import {useSplashScreenActions, useSplashScreenState} from './SplashScreenStateContext'; import type {ScreenShareRequest} from './types/onyx'; diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts index 6ed6bf06f807..836b321d7018 100755 --- a/src/ONYXKEYS.ts +++ b/src/ONYXKEYS.ts @@ -980,6 +980,8 @@ const ONYXKEYS = { SPLIT_EXPENSE_EDIT_DATES_DRAFT: 'splitExpenseEditDatesDraft', EXPENSE_RULE_FORM: 'expenseRuleForm', EXPENSE_RULE_FORM_DRAFT: 'expenseRuleFormDraft', + MERCHANT_RULE_FORM: 'merchantRuleForm', + MERCHANT_RULE_FORM_DRAFT: 'merchantRuleFormDraft', ADD_DOMAIN_MEMBER_FORM: 'addDomainMemberForm', ADD_DOMAIN_MEMBER_FORM_DRAFT: 'addDomainMemberFormDraft', }, @@ -1103,6 +1105,7 @@ type OnyxFormValuesMapping = { [ONYXKEYS.FORMS.CREATE_DOMAIN_FORM]: FormTypes.CreateDomainForm; [ONYXKEYS.FORMS.SPLIT_EXPENSE_EDIT_DATES]: FormTypes.SplitExpenseEditDateForm; [ONYXKEYS.FORMS.EXPENSE_RULE_FORM]: FormTypes.ExpenseRuleForm; + [ONYXKEYS.FORMS.MERCHANT_RULE_FORM]: FormTypes.MerchantRuleForm; [ONYXKEYS.FORMS.ADD_DOMAIN_MEMBER_FORM]: FormTypes.AddDomainMemberForm; }; diff --git a/src/ROUTES.ts b/src/ROUTES.ts index 83db556a2b2e..1d46e291aac4 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -60,7 +60,9 @@ const MULTIFACTOR_AUTHENTICATION_PROTECTED_ROUTES = { const ROUTES = { ...PUBLIC_SCREENS_ROUTES, // This route renders the list of reports. - HOME: 'home', + INBOX: 'home', + // @TODO: Rename it to 'home' and INBOX to 'inbox' when removing the newDotHome beta + HOME: 'home-page', // eslint-disable-next-line no-restricted-syntax -- Legacy route generation WORKSPACES_LIST: {route: 'workspaces', getRoute: (backTo?: string) => getUrlWithBackToParam('workspaces', backTo)}, @@ -2670,6 +2672,46 @@ const ROUTES = { route: 'workspaces/:policyID/overview/policy', getRoute: (policyID: string) => `workspaces/${policyID}/overview/policy` as const, }, + RULES_MERCHANT_NEW: { + route: 'workspaces/:policyID/rules/merchant-rules/new', + getRoute: (policyID: string) => `workspaces/${policyID}/rules/merchant-rules/new` as const, + }, + RULES_MERCHANT_MERCHANT_TO_MATCH: { + route: 'workspaces/:policyID/rules/merchant-rules/:ruleID/merchant-to-match', + getRoute: (policyID: string, ruleID?: string) => `workspaces/${policyID}/rules/merchant-rules/${ruleID ?? 'new'}/merchant-to-match` as const, + }, + RULES_MERCHANT_MERCHANT: { + route: 'workspaces/:policyID/rules/merchant-rules/:ruleID/merchant', + getRoute: (policyID: string, ruleID?: string) => `workspaces/${policyID}/rules/merchant-rules/${ruleID ?? 'new'}/merchant` as const, + }, + RULES_MERCHANT_CATEGORY: { + route: 'workspaces/:policyID/rules/merchant-rules/:ruleID/category', + getRoute: (policyID: string, ruleID?: string) => `workspaces/${policyID}/rules/merchant-rules/${ruleID ?? 'new'}/category` as const, + }, + RULES_MERCHANT_TAG: { + route: 'workspaces/:policyID/rules/merchant-rules/:ruleID/tag', + getRoute: (policyID: string, ruleID?: string) => `workspaces/${policyID}/rules/merchant-rules/${ruleID ?? 'new'}/tag` as const, + }, + RULES_MERCHANT_TAX: { + route: 'workspaces/:policyID/rules/merchant-rules/:ruleID/tax', + getRoute: (policyID: string, ruleID?: string) => `workspaces/${policyID}/rules/merchant-rules/${ruleID ?? 'new'}/tax` as const, + }, + RULES_MERCHANT_DESCRIPTION: { + route: 'workspaces/:policyID/rules/merchant-rules/:ruleID/description', + getRoute: (policyID: string, ruleID?: string) => `workspaces/${policyID}/rules/merchant-rules/${ruleID ?? 'new'}/description` as const, + }, + RULES_MERCHANT_REIMBURSABLE: { + route: 'workspaces/:policyID/rules/merchant-rules/:ruleID/reimbursable', + getRoute: (policyID: string, ruleID?: string) => `workspaces/${policyID}/rules/merchant-rules/${ruleID ?? 'new'}/reimbursable` as const, + }, + RULES_MERCHANT_BILLABLE: { + route: 'workspaces/:policyID/rules/merchant-rules/:ruleID/billable', + getRoute: (policyID: string, ruleID?: string) => `workspaces/${policyID}/rules/merchant-rules/${ruleID ?? 'new'}/billable` as const, + }, + RULES_MERCHANT_EDIT: { + route: 'workspaces/:policyID/rules/merchant-rules/:ruleID', + getRoute: (policyID: string, ruleID: string) => `workspaces/${policyID}/rules/merchant-rules/${ruleID}` as const, + }, // Referral program promotion REFERRAL_DETAILS_MODAL: { route: 'referral/:contentType', @@ -3744,6 +3786,8 @@ const ROUTES = { }, MULTIFACTOR_AUTHENTICATION_NOT_FOUND: 'multifactor-authentication/not-found', + + MULTIFACTOR_AUTHENTICATION_REVOKE: 'multifactor-authentication/revoke', } as const; /** diff --git a/src/SCREENS.ts b/src/SCREENS.ts index 05e77b043b87..2475a3b0238c 100644 --- a/src/SCREENS.ts +++ b/src/SCREENS.ts @@ -6,6 +6,7 @@ import type DeepValueOf from './types/utils/DeepValueOf'; const PROTECTED_SCREENS = { HOME: 'Home', + INBOX: 'Inbox', CONCIERGE: 'Concierge', REPORT_ATTACHMENTS: 'ReportAttachments', REPORT_ADD_ATTACHMENT: 'ReportAddAttachment', @@ -758,6 +759,16 @@ const SCREENS = { RULES_REIMBURSABLE_DEFAULT: 'Rules_Reimbursable_Default', RULES_CUSTOM: 'Rules_Custom', RULES_PROHIBITED_DEFAULT: 'Rules_Prohibited_Default', + RULES_MERCHANT_NEW: 'Rules_Merchant_New', + RULES_MERCHANT_MERCHANT_TO_MATCH: 'Rules_Merchant_Merchant_To_Match', + RULES_MERCHANT_MERCHANT: 'Rules_Merchant_Merchant', + RULES_MERCHANT_CATEGORY: 'Rules_Merchant_Category', + RULES_MERCHANT_TAG: 'Rules_Merchant_Tag', + RULES_MERCHANT_TAX: 'Rules_Merchant_Tax', + RULES_MERCHANT_DESCRIPTION: 'Rules_Merchant_Description', + RULES_MERCHANT_REIMBURSABLE: 'Rules_Merchant_Reimbursable', + RULES_MERCHANT_BILLABLE: 'Rules_Merchant_Billable', + RULES_MERCHANT_EDIT: 'Rules_Merchant_Edit', PER_DIEM: 'Per_Diem', PER_DIEM_IMPORT: 'Per_Diem_Import', PER_DIEM_IMPORTED: 'Per_Diem_Imported', @@ -916,6 +927,7 @@ const SCREENS = { OUTCOME: 'Multifactor_Authentication_Outcome', PROMPT: 'Multifactor_Authentication_Prompt', NOT_FOUND: 'Multifactor_Authentication_Not_Found', + REVOKE: 'Multifactor_Authentication_Revoke', }, } as const; diff --git a/src/components/AccountSwitcher.tsx b/src/components/AccountSwitcher.tsx index b347b69ebb96..997b8891a71b 100644 --- a/src/components/AccountSwitcher.tsx +++ b/src/components/AccountSwitcher.tsx @@ -15,7 +15,7 @@ import {clearDelegatorErrors, connect, disconnect} from '@libs/actions/Delegate' import {close} from '@libs/actions/Modal'; import {getLatestError} from '@libs/ErrorUtils'; import {getPersonalDetailByEmail} from '@libs/PersonalDetailsUtils'; -import TextWithEmojiFragment from '@pages/home/report/comment/TextWithEmojiFragment'; +import TextWithEmojiFragment from '@pages/inbox/report/comment/TextWithEmojiFragment'; import variables from '@styles/variables'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; diff --git a/src/components/AnchorForCommentsOnly/BaseAnchorForCommentsOnly.tsx b/src/components/AnchorForCommentsOnly/BaseAnchorForCommentsOnly.tsx index 5c34ce3fee4a..884dcf5d9436 100644 --- a/src/components/AnchorForCommentsOnly/BaseAnchorForCommentsOnly.tsx +++ b/src/components/AnchorForCommentsOnly/BaseAnchorForCommentsOnly.tsx @@ -11,7 +11,7 @@ import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useStyleUtils from '@hooks/useStyleUtils'; import useThemeStyles from '@hooks/useThemeStyles'; import {canUseTouchScreen} from '@libs/DeviceCapabilities'; -import {hideContextMenu, showContextMenu} from '@pages/home/report/ContextMenu/ReportActionContextMenu'; +import {hideContextMenu, showContextMenu} from '@pages/inbox/report/ContextMenu/ReportActionContextMenu'; import CONST from '@src/CONST'; import type {BaseAnchorForCommentsOnlyProps, LinkProps} from './types'; diff --git a/src/components/Charts/BarChart/BarChartContent.tsx b/src/components/Charts/BarChart/BarChartContent.tsx index 060b55b1fcd8..3b4516559a15 100644 --- a/src/components/Charts/BarChart/BarChartContent.tsx +++ b/src/components/Charts/BarChart/BarChartContent.tsx @@ -1,3 +1,4 @@ +import {useFont} from '@shopify/react-native-skia'; import React, {useCallback, useMemo, useState} from 'react'; import type {LayoutChangeEvent} from 'react-native'; import {View} from 'react-native'; @@ -5,8 +6,8 @@ import Animated, {useSharedValue} from 'react-native-reanimated'; import type {ChartBounds, PointsArray} from 'victory-native'; import {Bar, CartesianChart} from 'victory-native'; import ActivityIndicator from '@components/ActivityIndicator'; -import ChartHeader from '@components/Charts/components/ChartHeader'; import ChartTooltip from '@components/Charts/ChartTooltip'; +import ChartHeader from '@components/Charts/components/ChartHeader'; import { BAR_INNER_PADDING, BAR_ROUNDED_CORNERS, @@ -21,7 +22,6 @@ import { Y_AXIS_LINE_WIDTH, Y_AXIS_TICK_COUNT, } from '@components/Charts/constants'; -import {useFont} from '@shopify/react-native-skia'; import fontSource from '@components/Charts/font'; import type {HitTestArgs} from '@components/Charts/hooks'; import {useChartColors, useChartInteractions, useChartLabelFormats, useChartLabelLayout} from '@components/Charts/hooks'; diff --git a/src/components/Charts/hooks/useChartInteractionState.ts b/src/components/Charts/hooks/useChartInteractionState.ts index b08ee8fcf8e4..d9ccc636cfa0 100644 --- a/src/components/Charts/hooks/useChartInteractionState.ts +++ b/src/components/Charts/hooks/useChartInteractionState.ts @@ -1,4 +1,4 @@ -import {useMemo, useState} from 'react'; +import {useState} from 'react'; import type {SharedValue} from 'react-native-reanimated'; import {makeMutable, useAnimatedReaction} from 'react-native-reanimated'; import {scheduleOnRN} from 'react-native-worklets'; @@ -71,57 +71,38 @@ function useIsInteractionActive(state: C * * @param initialValues - Initial x and y values matching your chart data structure * @returns Object containing the interaction state and a boolean indicating if interaction is active - * - * @example - * ```tsx - * const { state, isActive } = useChartInteractionState({ - * x: '', - * y: { value: 0 } - * }); - * - * // Use with customGestures and actionsRef - * const hoverGesture = Gesture.Hover() - * .onUpdate((e) => { - * state.isActive.set(true); - * actionsRef.current?.handleTouch(state, e.x, e.y); - * }) - * .onEnd(() => { - * state.isActive.set(false); - * }); - * ``` */ -function useChartInteractionState(initialValues: Init): { +function useChartInteractionState( + initialValues: Init, +): { state: ChartInteractionState; isActive: boolean; } { - const keys = Object.keys(initialValues.y).join(','); - - const state = useMemo(() => { - const yState = {} as Record; position: SharedValue}>; - - for (const [key, initVal] of Object.entries(initialValues.y)) { - yState[key as keyof Init['y']] = { - value: makeMutable(initVal), - position: makeMutable(0), - }; - } + // The React Compiler will automatically memoize this object creation. + // We remove the explicit useMemo and dependency on 'keys'. + const yState = {} as Record; position: SharedValue}>; - return { - isActive: makeMutable(false), - matchedIndex: makeMutable(-1), - x: { - value: makeMutable(initialValues.x), - position: makeMutable(0), - }, - y: yState, - yIndex: makeMutable(-1), - cursor: { - x: makeMutable(0), - y: makeMutable(0), - }, + for (const [key, initVal] of Object.entries(initialValues.y)) { + yState[key as keyof Init['y']] = { + value: makeMutable(initVal), + position: makeMutable(0), }; - // eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps -- keys is a stable string representation of y keys - }, [keys]); + } + + const state: ChartInteractionState = { + isActive: makeMutable(false), + matchedIndex: makeMutable(-1), + x: { + value: makeMutable(initialValues.x), + position: makeMutable(0), + }, + y: yState, + yIndex: makeMutable(-1), + cursor: { + x: makeMutable(0), + y: makeMutable(0), + }, + }; const isActive = useIsInteractionActive(state); diff --git a/src/components/Charts/hooks/useChartInteractions.ts b/src/components/Charts/hooks/useChartInteractions.ts index e7a5422f5880..db1cd681e8f2 100644 --- a/src/components/Charts/hooks/useChartInteractions.ts +++ b/src/components/Charts/hooks/useChartInteractions.ts @@ -1,4 +1,4 @@ -import {useMemo, useRef, useState} from 'react'; +import {useRef, useState} from 'react'; import {Gesture} from 'react-native-gesture-handler'; import type {SharedValue} from 'react-native-reanimated'; import {useAnimatedReaction, useAnimatedStyle, useDerivedValue} from 'react-native-reanimated'; @@ -6,97 +6,45 @@ import {scheduleOnRN} from 'react-native-worklets'; import {TOOLTIP_BAR_GAP} from '@components/Charts/constants'; import {useChartInteractionState} from './useChartInteractionState'; -/** - * Arguments passed to the checkIsOver callback for hit-testing - */ +const INITIAL_INTERACTION_STATE = {x: 0, y: {y: 0}}; + type HitTestArgs = { - /** Current raw X position of the cursor */ cursorX: number; - /** Current raw Y position of the cursor */ cursorY: number; - /** Calculated X position of the matched data point */ targetX: number; - /** Calculated Y position of the matched data point */ targetY: number; - /** The bottom boundary of the chart area */ chartBottom: number; }; -/** - * Configuration for the chart interactions hook - */ type UseChartInteractionsProps = { - /** Callback triggered when a valid data point is tapped/clicked */ handlePress: (index: number) => void; - /** - * Worklet function to determine if the cursor is technically "hovering" - * over a specific chart element (e.g., within a bar's width or a point's radius). - */ checkIsOver: (args: HitTestArgs) => boolean; - /** Optional shared value containing bar dimensions used for hit-testing in bar charts */ barGeometry?: SharedValue<{barWidth: number; chartBottom: number; yZero: number}>; }; -/** - * Type for Victory's actionsRef handle. - * Used to manually trigger Victory's internal touch handling logic. - */ type CartesianActionsHandle = { handleTouch: (state: unknown, x: number, y: number) => void; }; -/** - * Hook to manage complex chart interactions including hover gestures (web), - * tap gestures (mobile/web), hit-testing, and animated tooltip positioning. - * - * It synchronizes high-frequency interaction data from the UI thread to React state - * for metadata display (like tooltips) and navigation. - * - * @param props - Configuration including press handlers and hit-test logic. - * @returns An object containing refs, gestures, and state for the chart component. - * - * @example - * ```tsx - * const { actionsRef, customGestures, activeDataIndex, isTooltipActive, tooltipStyle } = useChartInteractions({ - * handlePress: (index) => console.log("Pressed index:", index), - * checkIsOver: ({ cursorX, targetX, barWidth }) => { - * 'worklet'; - * return Math.abs(cursorX - targetX) < barWidth / 2; - * }, - * barGeometry: myBarSharedValue, - * }); - * - * return ( - * - * - * {isTooltipActive && } - * - * ); - * ``` - */ function useChartInteractions({handlePress, checkIsOver, barGeometry}: UseChartInteractionsProps) { - /** Interaction state compatible with Victory Native's internal logic */ - const {state: chartInteractionState, isActive: isTooltipActiveState} = useChartInteractionState({x: 0, y: {y: 0}}); - - /** Ref passed to CartesianChart to allow manual touch injection */ - const actionsRef = useRef(null); + const {state: chartInteractionState, isActive: isTooltipActiveState} = useChartInteractionState(INITIAL_INTERACTION_STATE); - /** React state for the index of the point currently being interacted with */ const [activeDataIndex, setActiveDataIndex] = useState(-1); - - /** React state indicating if the cursor is currently "hitting" a target based on checkIsOver */ const [isOverTarget, setIsOverTarget] = useState(false); - /** - * Derived value performing the hit-test on the UI thread. - * Runs whenever cursor position or matched data points change. - */ + const actionsRef = useRef(null); + + const handleTouchWorklet = useDerivedValue(() => { + return actionsRef.current?.handleTouch; + }); + const isCursorOverTarget = useDerivedValue(() => { + 'worklet'; + const cursorX = chartInteractionState.cursor.x.get(); const cursorY = chartInteractionState.cursor.y.get(); const targetX = chartInteractionState.x.position.get(); const targetY = chartInteractionState.y.y.position.get(); - const chartBottom = barGeometry?.get().chartBottom ?? 0; return checkIsOver({ @@ -108,7 +56,6 @@ function useChartInteractions({handlePress, checkIsOver, barGeometry}: UseChartI }); }); - /** Syncs the matched data index from the UI thread to React state */ useAnimatedReaction( () => chartInteractionState.matchedIndex.get(), (currentIndex) => { @@ -116,7 +63,6 @@ function useChartInteractions({handlePress, checkIsOver, barGeometry}: UseChartI }, ); - /** Syncs the hit-test result from the UI thread to React state */ useAnimatedReaction( () => isCursorOverTarget.get(), (isOver) => { @@ -124,94 +70,76 @@ function useChartInteractions({handlePress, checkIsOver, barGeometry}: UseChartI }, ); - /** - * Hover gesture configuration. - * Primarily used for web/desktop to track mouse movement without clicking. - */ - const hoverGesture = useMemo( - () => - Gesture.Hover() - .onBegin((e) => { - 'worklet'; - - chartInteractionState.isActive.set(true); - chartInteractionState.cursor.x.set(e.x); - chartInteractionState.cursor.y.set(e.y); - actionsRef.current?.handleTouch(chartInteractionState, e.x, e.y); - }) - .onUpdate((e) => { - 'worklet'; - - chartInteractionState.cursor.x.set(e.x); - chartInteractionState.cursor.y.set(e.y); - actionsRef.current?.handleTouch(chartInteractionState, e.x, e.y); - }) - .onEnd(() => { - 'worklet'; - - chartInteractionState.isActive.set(false); - }), - [chartInteractionState], - ); + const hoverGesture = Gesture.Hover() + .onBegin((e) => { + 'worklet'; + + chartInteractionState.isActive.set(true); + chartInteractionState.cursor.x.set(e.x); + chartInteractionState.cursor.y.set(e.y); + + const touchFn = handleTouchWorklet.get(); + if (touchFn) { + touchFn(chartInteractionState, e.x, e.y); + } + }) + .onUpdate((e) => { + 'worklet'; + + chartInteractionState.cursor.x.set(e.x); + chartInteractionState.cursor.y.set(e.y); + + const touchFn = handleTouchWorklet.get(); + if (touchFn) { + touchFn(chartInteractionState, e.x, e.y); + } + }) + .onEnd(() => { + 'worklet'; + + chartInteractionState.isActive.set(false); + }); - /** - * Tap gesture configuration. - * Handles clicks/touches and triggers handlePress if Victory matched a data point. - */ - const tapGesture = useMemo( - () => - Gesture.Tap().onEnd((e) => { - 'worklet'; - - // Update cursor position - chartInteractionState.cursor.x.set(e.x); - chartInteractionState.cursor.y.set(e.y); - - // Let Victory calculate which data point was tapped - actionsRef.current?.handleTouch(chartInteractionState, e.x, e.y); - const matchedIndex = chartInteractionState.matchedIndex.get(); - - // If Victory matched a valid data point, trigger the press handler - if (matchedIndex >= 0) { - scheduleOnRN(handlePress, matchedIndex); - } - }), - [chartInteractionState, handlePress], - ); + const tapGesture = Gesture.Tap().onEnd((e) => { + 'worklet'; + + chartInteractionState.cursor.x.set(e.x); + chartInteractionState.cursor.y.set(e.y); + + const touchFn = handleTouchWorklet.get(); + if (touchFn) { + touchFn(chartInteractionState, e.x, e.y); + } + + const matchedIndex = chartInteractionState.matchedIndex.get(); + if (matchedIndex >= 0 && isCursorOverTarget.get()) { + scheduleOnRN(handlePress, matchedIndex); + } + }); - /** Combined gesture object to be passed to CartesianChart's customGestures prop */ - const customGestures = useMemo(() => Gesture.Race(hoverGesture, tapGesture), [hoverGesture, tapGesture]); + const customGestures = Gesture.Race(hoverGesture, tapGesture); - /** - * Animated style for positioning a tooltip relative to the matched data point. - * Automatically applies vertical offset and centering. - * For negative bars, positions tooltip at yZero (top of bar) instead of targetY (bottom of bar). - */ const tooltipStyle = useAnimatedStyle(() => { + const posX = chartInteractionState.x.position.get(); const targetY = chartInteractionState.y.y.position.get(); const yZero = barGeometry?.get().yZero ?? targetY; - // Position tooltip at the top of the bar (min of targetY and yZero) const barTopY = Math.min(targetY, yZero); + const isVisible = chartInteractionState.isActive.get() && isCursorOverTarget.get(); return { position: 'absolute', - left: chartInteractionState.x.position.get(), + left: posX, top: barTopY - TOOLTIP_BAR_GAP, transform: [{translateX: '-50%'}, {translateY: '-100%'}], - opacity: chartInteractionState.isActive.get() ? 1 : 0, + opacity: isVisible ? 1 : 0, }; }); return { - /** Ref to be passed to CartesianChart */ actionsRef, - /** Gestures to be passed to CartesianChart */ customGestures, - /** The currently active data index (React state) */ activeDataIndex, - /** Whether the tooltip should currently be rendered and visible */ - isTooltipActive: isOverTarget && isTooltipActiveState, - /** Animated styles for the tooltip container */ + isTooltipActive: isTooltipActiveState && isOverTarget, tooltipStyle, }; } diff --git a/src/components/Charts/hooks/useChartLabelFormats.ts b/src/components/Charts/hooks/useChartLabelFormats.ts index 3f6136c5d262..afe2a2aaaced 100644 --- a/src/components/Charts/hooks/useChartLabelFormats.ts +++ b/src/components/Charts/hooks/useChartLabelFormats.ts @@ -1,5 +1,3 @@ -import {useCallback} from 'react'; - type ChartDataPoint = { label: string; }; @@ -13,38 +11,31 @@ type UseChartLabelFormatsProps = { truncatedLabels: string[]; }; +/** + * Hook for styling chart labels. + */ export default function useChartLabelFormats({data, yAxisUnit, yAxisUnitPosition = 'left', labelSkipInterval, labelRotation, truncatedLabels}: UseChartLabelFormatsProps) { - const formatYAxisLabel = useCallback( - (value: number) => { - const formatted = value.toLocaleString(); - if (!yAxisUnit) { - return formatted; - } - // Add space for multi-character codes (e.g., "PLN 100") but not for symbols (e.g., "$100") - const separator = yAxisUnit.length > 1 ? ' ' : ''; - return yAxisUnitPosition === 'left' ? `${yAxisUnit}${separator}${formatted}` : `${formatted}${separator}${yAxisUnit}`; - }, - [yAxisUnit, yAxisUnitPosition], - ); - - const formatXAxisLabel = useCallback( - (value: number) => { - const index = Math.round(value); - - // Skip labels based on calculated interval - if (index % labelSkipInterval !== 0) { - return ''; - } - - // Use pre-truncated labels - // If rotation is vertical (-90), we usually want full labels - // because they have more space vertically. - const sourceToUse = labelRotation === -90 ? data.map((p) => p.label) : truncatedLabels; - - return sourceToUse.at(index) ?? ''; - }, - [labelSkipInterval, labelRotation, truncatedLabels, data], - ); + const formatYAxisLabel = (value: number) => { + const formatted = value.toLocaleString(); + if (!yAxisUnit) { + return formatted; + } + + const separator = yAxisUnit.length > 1 ? ' ' : ''; + return yAxisUnitPosition === 'left' ? `${yAxisUnit}${separator}${formatted}` : `${formatted}${separator}${yAxisUnit}`; + }; + + const formatXAxisLabel = (value: number) => { + const index = Math.round(value); + + if (index % labelSkipInterval !== 0) { + return ''; + } + + const sourceToUse = labelRotation === -90 ? data.map((p) => p.label) : truncatedLabels; + + return sourceToUse.at(index) ?? ''; + }; return { formatXAxisLabel, diff --git a/src/components/DisplayNames/index.native.tsx b/src/components/DisplayNames/index.native.tsx index b59564609767..a3aedfcd8b53 100644 --- a/src/components/DisplayNames/index.native.tsx +++ b/src/components/DisplayNames/index.native.tsx @@ -4,7 +4,7 @@ import useLocalize from '@hooks/useLocalize'; import {containsCustomEmoji, containsOnlyCustomEmoji} from '@libs/EmojiUtils'; import Parser from '@libs/Parser'; import StringUtils from '@libs/StringUtils'; -import TextWithEmojiFragment from '@pages/home/report/comment/TextWithEmojiFragment'; +import TextWithEmojiFragment from '@pages/inbox/report/comment/TextWithEmojiFragment'; import type DisplayNamesProps from './types'; // As we don't have to show tooltips of the Native platform so we simply render the full display names list. diff --git a/src/components/EmptyStateComponent/index.tsx b/src/components/EmptyStateComponent/index.tsx index 9e3cb9dd0126..ced3a2025f0e 100644 --- a/src/components/EmptyStateComponent/index.tsx +++ b/src/components/EmptyStateComponent/index.tsx @@ -11,7 +11,7 @@ import VideoPlayer from '@components/VideoPlayer'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useThemeStyles from '@hooks/useThemeStyles'; import {containsCustomEmoji, containsOnlyCustomEmoji} from '@libs/EmojiUtils'; -import TextWithEmojiFragment from '@pages/home/report/comment/TextWithEmojiFragment'; +import TextWithEmojiFragment from '@pages/inbox/report/comment/TextWithEmojiFragment'; import CONST from '@src/CONST'; import type {EmptyStateComponentProps} from './types'; diff --git a/src/components/FloatingActionButton.tsx b/src/components/FloatingActionButton.tsx index 783fe383530a..debb870f8649 100644 --- a/src/components/FloatingActionButton.tsx +++ b/src/components/FloatingActionButton.tsx @@ -6,6 +6,7 @@ import {View} from 'react-native'; import Animated, {Easing, interpolateColor, useAnimatedStyle, useSharedValue, withTiming} from 'react-native-reanimated'; import Svg, {Path} from 'react-native-svg'; import useLocalize from '@hooks/useLocalize'; +import usePermissions from '@hooks/usePermissions'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -53,6 +54,8 @@ function FloatingActionButton({onPress, onLongPress, isActive, accessibilityLabe const {shouldUseNarrowLayout} = useResponsiveLayout(); const isLHBVisible = !shouldUseNarrowLayout; const {translate} = useLocalize(); + const {isBetaEnabled} = usePermissions(); + const isNewDotHomeEnabled = isBetaEnabled(CONST.BETAS.NEW_DOT_HOME); const fabSize = isLHBVisible ? variables.iconSizeSmall : variables.iconSizeNormal; @@ -94,7 +97,7 @@ function FloatingActionButton({onPress, onLongPress, isActive, accessibilityLabe onLongPress?.(event); }; - if (isLHBVisible) { + if (isLHBVisible || isNewDotHomeEnabled) { return ( generateReportID(), []); + const {isBetaEnabled} = usePermissions(); + const isNewDotHomeEnabled = isBetaEnabled(CONST.BETAS.NEW_DOT_HOME); + const policyChatForActivePolicySelector = useCallback( (reports: OnyxCollection) => { if (isEmptyObject(activePolicy) || !activePolicy?.isPolicyExpenseChatEnabled) { @@ -71,7 +75,7 @@ function BaseFloatingCameraButton({icon}: BaseFloatingCameraButtonProps) { styles.ph0, // Prevent text selection on touch devices (e.g. on long press) canUseTouchScreen() && styles.userSelectNone, - styles.floatingCameraButton, + isNewDotHomeEnabled ? styles.floatingCameraButtonAboveFab : styles.floatingCameraButton, ]} accessibilityLabel={translate('sidebarScreen.fabScanReceiptExplained')} onPress={onPress} diff --git a/src/components/FloatingGPSButton/index.native.tsx b/src/components/FloatingGPSButton/index.native.tsx index 0d8d4c2c8b87..acc69ad000f4 100644 --- a/src/components/FloatingGPSButton/index.native.tsx +++ b/src/components/FloatingGPSButton/index.native.tsx @@ -5,6 +5,7 @@ import {PressableWithoutFeedback} from '@components/Pressable'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; +import usePermissions from '@hooks/usePermissions'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import Navigation from '@libs/Navigation/Navigation'; @@ -22,6 +23,9 @@ function FloatingGpsButton() { const {textMutedReversed} = useTheme(); const styles = useThemeStyles(); + const {isBetaEnabled} = usePermissions(); + const isNewDotHomeEnabled = isBetaEnabled(CONST.BETAS.NEW_DOT_HOME); + if (!gpsDraftDetails?.isTracking) { return null; } @@ -33,7 +37,7 @@ function FloatingGpsButton() { return ( { setIsUsingImportedState(true); - Navigation.navigate(ROUTES.HOME); + Navigation.navigate(ROUTES.INBOX); }) .catch((error) => { console.error('Error importing state:', error); diff --git a/src/components/ImportOnyxState/index.tsx b/src/components/ImportOnyxState/index.tsx index e6ed2316b108..aaeadb76994c 100644 --- a/src/components/ImportOnyxState/index.tsx +++ b/src/components/ImportOnyxState/index.tsx @@ -42,7 +42,7 @@ export default function ImportOnyxState({setIsLoading}: ImportOnyxStateProps) { }) .then(() => { setIsUsingImportedState(true); - Navigation.navigate(ROUTES.HOME); + Navigation.navigate(ROUTES.INBOX); }) .catch((error) => { console.error('Error importing state:', error); diff --git a/src/components/KYCWall/BaseKYCWall.tsx b/src/components/KYCWall/BaseKYCWall.tsx index e4a116e4b5c1..ecad604578c0 100644 --- a/src/components/KYCWall/BaseKYCWall.tsx +++ b/src/components/KYCWall/BaseKYCWall.tsx @@ -135,7 +135,7 @@ function KYCWall({ if (paymentMethod === CONST.PAYMENT_METHODS.PERSONAL_BANK_ACCOUNT) { openPersonalBankAccountSetupView({shouldSetUpUSBankAccount: isIOUReport(iouReport)}); } else if (paymentMethod === CONST.PAYMENT_METHODS.DEBIT_CARD) { - Navigation.navigate(addDebitCardRoute ?? ROUTES.HOME); + Navigation.navigate(addDebitCardRoute ?? ROUTES.INBOX); } else if (paymentMethod === CONST.PAYMENT_METHODS.BUSINESS_BANK_ACCOUNT || policy) { if (iouReport && isIOUReport(iouReport)) { const adminPolicy = policies?.[`${ONYXKEYS.COLLECTION.POLICY}${policy?.id}`]; diff --git a/src/components/LHNOptionsList/OptionRowLHN.tsx b/src/components/LHNOptionsList/OptionRowLHN.tsx index 16790819d875..df0bd683c2f9 100644 --- a/src/components/LHNOptionsList/OptionRowLHN.tsx +++ b/src/components/LHNOptionsList/OptionRowLHN.tsx @@ -29,8 +29,8 @@ import Performance from '@libs/Performance'; import ReportActionComposeFocusManager from '@libs/ReportActionComposeFocusManager'; import {isAdminRoom, isChatUsedForOnboarding as isChatUsedForOnboardingReportUtils, isConciergeChatReport, isGroupChat, isOneOnOneChat, isSystemChat} from '@libs/ReportUtils'; import {startSpan} from '@libs/telemetry/activeSpans'; -import TextWithEmojiFragment from '@pages/home/report/comment/TextWithEmojiFragment'; -import {showContextMenu} from '@pages/home/report/ContextMenu/ReportActionContextMenu'; +import TextWithEmojiFragment from '@pages/inbox/report/comment/TextWithEmojiFragment'; +import {showContextMenu} from '@pages/inbox/report/ContextMenu/ReportActionContextMenu'; import FreeTrial from '@pages/settings/Subscription/FreeTrial'; import variables from '@styles/variables'; import Timing from '@userActions/Timing'; diff --git a/src/components/MenuItem.tsx b/src/components/MenuItem.tsx index 23d5d73eb3d2..ba06841c08c1 100644 --- a/src/components/MenuItem.tsx +++ b/src/components/MenuItem.tsx @@ -18,8 +18,8 @@ import getButtonState from '@libs/getButtonState'; import mergeRefs from '@libs/mergeRefs'; import Parser from '@libs/Parser'; import type {AvatarSource} from '@libs/UserAvatarUtils'; -import TextWithEmojiFragment from '@pages/home/report/comment/TextWithEmojiFragment'; -import {showContextMenu} from '@pages/home/report/ContextMenu/ReportActionContextMenu'; +import TextWithEmojiFragment from '@pages/inbox/report/comment/TextWithEmojiFragment'; +import {showContextMenu} from '@pages/inbox/report/ContextMenu/ReportActionContextMenu'; import variables from '@styles/variables'; import {callFunctionIfActionIsAllowed} from '@userActions/Session'; import CONST from '@src/CONST'; diff --git a/src/components/MenuItemList.tsx b/src/components/MenuItemList.tsx index e45621aa65bd..3c6b15d3ca1b 100644 --- a/src/components/MenuItemList.tsx +++ b/src/components/MenuItemList.tsx @@ -3,7 +3,7 @@ import React, {useRef} from 'react'; import type {GestureResponderEvent, StyleProp, View, ViewStyle} from 'react-native'; import useSingleExecution from '@hooks/useSingleExecution'; import mergeRefs from '@libs/mergeRefs'; -import {showContextMenu} from '@pages/home/report/ContextMenu/ReportActionContextMenu'; +import {showContextMenu} from '@pages/inbox/report/ContextMenu/ReportActionContextMenu'; import CONST from '@src/CONST'; import type * as OnyxCommon from '@src/types/onyx/OnyxCommon'; import type IconAsset from '@src/types/utils/IconAsset'; diff --git a/src/components/MoneyReportHeader.tsx b/src/components/MoneyReportHeader.tsx index c4b7c7cea435..814d0658badd 100644 --- a/src/components/MoneyReportHeader.tsx +++ b/src/components/MoneyReportHeader.tsx @@ -101,7 +101,7 @@ import { isScanning, shouldShowBrokenConnectionViolationForMultipleTransactions, } from '@libs/TransactionUtils'; -import type {ExportType} from '@pages/home/report/ReportDetailsExportPage'; +import type {ExportType} from '@pages/inbox/report/ReportDetailsExportPage'; import variables from '@styles/variables'; import { approveMoneyRequest, diff --git a/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx b/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx index 6c03be04eef0..f63c39d6eacf 100644 --- a/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx +++ b/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx @@ -58,11 +58,11 @@ import markOpenReportEnd from '@libs/telemetry/markOpenReportEnd'; import {isTransactionPendingDelete} from '@libs/TransactionUtils'; import Visibility from '@libs/Visibility'; import isSearchTopmostFullScreenRoute from '@navigation/helpers/isSearchTopmostFullScreenRoute'; -import FloatingMessageCounter from '@pages/home/report/FloatingMessageCounter'; -import getInitialNumToRender from '@pages/home/report/getInitialNumReportActionsToRender'; -import ReportActionsListItemRenderer from '@pages/home/report/ReportActionsListItemRenderer'; -import shouldDisplayNewMarkerOnReportAction from '@pages/home/report/shouldDisplayNewMarkerOnReportAction'; -import useReportUnreadMessageScrollTracking from '@pages/home/report/useReportUnreadMessageScrollTracking'; +import FloatingMessageCounter from '@pages/inbox/report/FloatingMessageCounter'; +import getInitialNumToRender from '@pages/inbox/report/getInitialNumReportActionsToRender'; +import ReportActionsListItemRenderer from '@pages/inbox/report/ReportActionsListItemRenderer'; +import shouldDisplayNewMarkerOnReportAction from '@pages/inbox/report/shouldDisplayNewMarkerOnReportAction'; +import useReportUnreadMessageScrollTracking from '@pages/inbox/report/useReportUnreadMessageScrollTracking'; import variables from '@styles/variables'; import {openReport, readNewestAction, subscribeToNewActionEvent} from '@userActions/Report'; import CONST from '@src/CONST'; diff --git a/src/components/MoneyRequestReportView/MoneyRequestReportView.tsx b/src/components/MoneyRequestReportView/MoneyRequestReportView.tsx index f96ce35ec9f3..324fd1a748b0 100644 --- a/src/components/MoneyRequestReportView/MoneyRequestReportView.tsx +++ b/src/components/MoneyRequestReportView/MoneyRequestReportView.tsx @@ -29,8 +29,8 @@ import {canEditReportAction, getReportOfflinePendingActionAndErrors, isReportTra import {buildCannedSearchQuery} from '@libs/SearchQueryUtils'; import {cancelSpan} from '@libs/telemetry/activeSpans'; import Navigation from '@navigation/Navigation'; -import ReportActionsView from '@pages/home/report/ReportActionsView'; -import ReportFooter from '@pages/home/report/ReportFooter'; +import ReportActionsView from '@pages/inbox/report/ReportActionsView'; +import ReportFooter from '@pages/inbox/report/ReportFooter'; import CONST from '@src/CONST'; import NAVIGATORS from '@src/NAVIGATORS'; import ONYXKEYS from '@src/ONYXKEYS'; diff --git a/src/components/Navigation/DebugTabView.tsx b/src/components/Navigation/DebugTabView.tsx index 2af72a9b1107..304dca6145c6 100644 --- a/src/components/Navigation/DebugTabView.tsx +++ b/src/components/Navigation/DebugTabView.tsx @@ -110,7 +110,7 @@ function DebugTabView({selectedTab, chatTabBrickRoad}: DebugTabViewProps) { const {orderedReportIDs} = useSidebarOrderedReports(); const message = useMemo((): TranslationPaths | undefined => { - if (selectedTab === NAVIGATION_TABS.HOME) { + if (selectedTab === NAVIGATION_TABS.INBOX) { if (chatTabBrickRoad === CONST.BRICK_ROAD_INDICATOR_STATUS.INFO) { return 'debug.indicatorStatus.theresAReportAwaitingAction'; } @@ -124,7 +124,7 @@ function DebugTabView({selectedTab, chatTabBrickRoad}: DebugTabViewProps) { }, [selectedTab, chatTabBrickRoad, status]); const indicator = useMemo(() => { - if (selectedTab === NAVIGATION_TABS.HOME) { + if (selectedTab === NAVIGATION_TABS.INBOX) { if (chatTabBrickRoad === CONST.BRICK_ROAD_INDICATOR_STATUS.INFO) { return theme.success; } @@ -140,7 +140,7 @@ function DebugTabView({selectedTab, chatTabBrickRoad}: DebugTabViewProps) { }, [selectedTab, chatTabBrickRoad, theme.success, theme.danger, status, indicatorColor]); const navigateTo = useCallback(() => { - if (selectedTab === NAVIGATION_TABS.HOME && !!chatTabBrickRoad) { + if (selectedTab === NAVIGATION_TABS.INBOX && !!chatTabBrickRoad) { const reportID = getChatTabBrickRoadReportID(orderedReportIDs, reportAttributes); if (reportID) { @@ -156,7 +156,7 @@ function DebugTabView({selectedTab, chatTabBrickRoad}: DebugTabViewProps) { } }, [selectedTab, chatTabBrickRoad, orderedReportIDs, reportAttributes, status, reimbursementAccount, policyIDWithErrors]); - if (!([NAVIGATION_TABS.HOME, NAVIGATION_TABS.SETTINGS, NAVIGATION_TABS.WORKSPACES] as string[]).includes(selectedTab ?? '') || !indicator) { + if (!([NAVIGATION_TABS.INBOX, NAVIGATION_TABS.SETTINGS, NAVIGATION_TABS.WORKSPACES] as string[]).includes(selectedTab ?? '') || !indicator) { return null; } diff --git a/src/components/Navigation/NavigationTabBar/HomeNavigationTabBar.tsx b/src/components/Navigation/NavigationTabBar/HomeNavigationTabBar.tsx new file mode 100644 index 000000000000..30648c47f653 --- /dev/null +++ b/src/components/Navigation/NavigationTabBar/HomeNavigationTabBar.tsx @@ -0,0 +1,611 @@ +import {findFocusedRoute, StackActions, useNavigationState} from '@react-navigation/native'; +import reportsSelector from '@selectors/Attributes'; +import React, {memo, useCallback, useEffect, useMemo, useState} from 'react'; +import {View} from 'react-native'; +import type {OnyxCollection} from 'react-native-onyx'; +import type {ValueOf} from 'type-fest'; +import FloatingCameraButton from '@components/FloatingCameraButton'; +import FloatingGPSButton from '@components/FloatingGPSButton'; +import Icon from '@components/Icon'; +import ImageSVG from '@components/ImageSVG'; +import DebugTabView from '@components/Navigation/DebugTabView'; +import {PressableWithFeedback} from '@components/Pressable'; +import Text from '@components/Text'; +import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; +import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; +import useLocalize from '@hooks/useLocalize'; +import useOnyx from '@hooks/useOnyx'; +import useResponsiveLayout from '@hooks/useResponsiveLayout'; +import useSearchTypeMenuSections from '@hooks/useSearchTypeMenuSections'; +import {useSidebarOrderedReports} from '@hooks/useSidebarOrderedReports'; +import useStyleUtils from '@hooks/useStyleUtils'; +import useSubscriptionPlan from '@hooks/useSubscriptionPlan'; +import useTheme from '@hooks/useTheme'; +import useThemeStyles from '@hooks/useThemeStyles'; +import useWorkspacesTabIndicatorStatus from '@hooks/useWorkspacesTabIndicatorStatus'; +import clearSelectedText from '@libs/clearSelectedText/clearSelectedText'; +import interceptAnonymousUser from '@libs/interceptAnonymousUser'; +import {getPreservedNavigatorState} from '@libs/Navigation/AppNavigator/createSplitNavigator/usePreserveNavigatorState'; +import getAccountTabScreenToOpen from '@libs/Navigation/helpers/getAccountTabScreenToOpen'; +import isRoutePreloaded from '@libs/Navigation/helpers/isRoutePreloaded'; +import navigateToWorkspacesPage, {getWorkspaceNavigationRouteState} from '@libs/Navigation/helpers/navigateToWorkspacesPage'; +import Navigation from '@libs/Navigation/Navigation'; +import {buildCannedSearchQuery, buildSearchQueryJSON, buildSearchQueryString} from '@libs/SearchQueryUtils'; +import {getDefaultActionableSearchMenuItem} from '@libs/SearchUIUtils'; +import {startSpan} from '@libs/telemetry/activeSpans'; +import type {BrickRoad} from '@libs/WorkspacesSettingsUtils'; +import {getChatTabBrickRoad} from '@libs/WorkspacesSettingsUtils'; +import navigationRef from '@navigation/navigationRef'; +import type {DomainSplitNavigatorParamList, RootNavigatorParamList, SearchFullscreenNavigatorParamList, State, WorkspaceSplitNavigatorParamList} from '@navigation/types'; +import NavigationTabBarAvatar from '@pages/inbox/sidebar/NavigationTabBarAvatar'; +import NavigationTabBarFloatingActionButton from '@pages/inbox/sidebar/NavigationTabBarFloatingActionButton'; +import variables from '@styles/variables'; +import CONST from '@src/CONST'; +import NAVIGATORS from '@src/NAVIGATORS'; +import ONYXKEYS from '@src/ONYXKEYS'; +import ROUTES from '@src/ROUTES'; +import SCREENS from '@src/SCREENS'; +import type {Domain, Policy} from '@src/types/onyx'; +import NAVIGATION_TABS from './NAVIGATION_TABS'; + +type NavigationTabBarProps = { + selectedTab: ValueOf; + isTopLevelBar?: boolean; + shouldShowFloatingButtons?: boolean; +}; + +function HomeNavigationTabBar({selectedTab, isTopLevelBar = false, shouldShowFloatingButtons = true}: NavigationTabBarProps) { + const theme = useTheme(); + const styles = useThemeStyles(); + + const getIconFill = useCallback( + (isSelected: boolean, isHovered: boolean) => { + if (isSelected) { + return theme.iconMenu; + } + if (isHovered) { + return theme.success; + } + return theme.icon; + }, + [theme], + ); + const {translate, preferredLocale} = useLocalize(); + const {indicatorColor: workspacesTabIndicatorColor, status: workspacesTabIndicatorStatus} = useWorkspacesTabIndicatorStatus(); + const {orderedReportIDs} = useSidebarOrderedReports(); + const [isDebugModeEnabled] = useOnyx(ONYXKEYS.IS_DEBUG_MODE_ENABLED, {canBeMissing: true}); + const [savedSearches] = useOnyx(ONYXKEYS.SAVED_SEARCHES, {canBeMissing: true}); + const navigationState = useNavigationState(findFocusedRoute); + const initialNavigationRouteState = getWorkspaceNavigationRouteState(); + const [lastWorkspacesTabNavigatorRoute, setLastWorkspacesTabNavigatorRoute] = useState(initialNavigationRouteState.lastWorkspacesTabNavigatorRoute); + const [workspacesTabState, setWorkspacesTabState] = useState(initialNavigationRouteState.workspacesTabState); + const params = workspacesTabState?.routes?.at(0)?.params as + | WorkspaceSplitNavigatorParamList[typeof SCREENS.WORKSPACE.INITIAL] + | DomainSplitNavigatorParamList[typeof SCREENS.DOMAIN.INITIAL]; + const {typeMenuSections} = useSearchTypeMenuSections(); + const subscriptionPlan = useSubscriptionPlan(); + const expensifyIcons = useMemoizedLazyExpensifyIcons(['ExpensifyAppIcon', 'Home', 'Inbox', 'MoneySearch', 'Buildings']); + + const paramsPolicyID = params && 'policyID' in params ? params.policyID : undefined; + const paramsDomainAccountID = params && 'domainAccountID' in params ? params.domainAccountID : undefined; + + const lastViewedPolicySelector = useCallback( + (policies: OnyxCollection) => { + if (!lastWorkspacesTabNavigatorRoute || lastWorkspacesTabNavigatorRoute.name !== NAVIGATORS.WORKSPACE_SPLIT_NAVIGATOR || !paramsPolicyID) { + return undefined; + } + + return policies?.[`${ONYXKEYS.COLLECTION.POLICY}${paramsPolicyID}`]; + }, + [paramsPolicyID, lastWorkspacesTabNavigatorRoute], + ); + + const [lastViewedPolicy] = useOnyx( + ONYXKEYS.COLLECTION.POLICY, + { + canBeMissing: true, + selector: lastViewedPolicySelector, + }, + [navigationState], + ); + + const lastViewedDomainSelector = useCallback( + (domains: OnyxCollection) => { + if (!lastWorkspacesTabNavigatorRoute || lastWorkspacesTabNavigatorRoute.name !== NAVIGATORS.DOMAIN_SPLIT_NAVIGATOR || !paramsDomainAccountID) { + return undefined; + } + + return domains?.[`${ONYXKEYS.COLLECTION.DOMAIN}${paramsDomainAccountID}`]; + }, + [paramsDomainAccountID, lastWorkspacesTabNavigatorRoute], + ); + + const [lastViewedDomain] = useOnyx( + ONYXKEYS.COLLECTION.DOMAIN, + { + canBeMissing: true, + selector: lastViewedDomainSelector, + }, + [navigationState], + ); + + const [reportAttributes] = useOnyx(ONYXKEYS.DERIVED.REPORT_ATTRIBUTES, {selector: reportsSelector, canBeMissing: true}); + const {login: currentUserLogin} = useCurrentUserPersonalDetails(); + const {shouldUseNarrowLayout} = useResponsiveLayout(); + const [chatTabBrickRoad, setChatTabBrickRoad] = useState(undefined); + + const StyleUtils = useStyleUtils(); + + useEffect(() => { + const newWorkspacesTabState = getWorkspaceNavigationRouteState(); + const newLastRoute = newWorkspacesTabState.lastWorkspacesTabNavigatorRoute; + const newTabState = newWorkspacesTabState.workspacesTabState; + + setLastWorkspacesTabNavigatorRoute(newLastRoute); + setWorkspacesTabState(newTabState); + }, [navigationState]); + + // On a wide layout DebugTabView should be rendered only within the navigation tab bar displayed directly on screens. + const shouldRenderDebugTabViewOnWideLayout = !!isDebugModeEnabled && !isTopLevelBar; + + useEffect(() => { + setChatTabBrickRoad(getChatTabBrickRoad(orderedReportIDs, reportAttributes)); + }, [orderedReportIDs, reportAttributes]); + + const navigateToNewDotHome = useCallback(() => { + if (selectedTab === NAVIGATION_TABS.HOME) { + return; + } + Navigation.navigate(ROUTES.HOME); + }, [selectedTab]); + + const navigateToChats = useCallback(() => { + if (selectedTab === NAVIGATION_TABS.INBOX) { + return; + } + + startSpan(CONST.TELEMETRY.SPAN_NAVIGATE_TO_INBOX_TAB, { + name: CONST.TELEMETRY.SPAN_NAVIGATE_TO_INBOX_TAB, + op: CONST.TELEMETRY.SPAN_NAVIGATE_TO_INBOX_TAB, + }); + + if (!shouldUseNarrowLayout && isRoutePreloaded(NAVIGATORS.REPORTS_SPLIT_NAVIGATOR)) { + // We use dispatch here because the correct screens and params are preloaded and set up in usePreloadFullScreenNavigators. + navigationRef.dispatch(StackActions.push(NAVIGATORS.REPORTS_SPLIT_NAVIGATOR)); + return; + } + + Navigation.navigate(ROUTES.INBOX); + }, [selectedTab, shouldUseNarrowLayout]); + + const [lastSearchParams] = useOnyx(ONYXKEYS.REPORT_NAVIGATION_LAST_SEARCH_QUERY, {canBeMissing: true}); + + const navigateToSearch = useCallback(() => { + if (selectedTab === NAVIGATION_TABS.SEARCH) { + return; + } + clearSelectedText(); + interceptAnonymousUser(() => { + const parentSpan = startSpan(CONST.TELEMETRY.SPAN_NAVIGATE_TO_REPORTS_TAB, { + name: CONST.TELEMETRY.SPAN_NAVIGATE_TO_REPORTS_TAB, + op: CONST.TELEMETRY.SPAN_NAVIGATE_TO_REPORTS_TAB, + }); + + startSpan(CONST.TELEMETRY.SPAN_ON_LAYOUT_SKELETON_REPORTS, { + name: CONST.TELEMETRY.SPAN_ON_LAYOUT_SKELETON_REPORTS, + op: CONST.TELEMETRY.SPAN_ON_LAYOUT_SKELETON_REPORTS, + parentSpan, + }); + + const rootState = navigationRef.getRootState() as State; + const lastSearchNavigator = rootState.routes.findLast((route) => route.name === NAVIGATORS.SEARCH_FULLSCREEN_NAVIGATOR); + const lastSearchNavigatorState = lastSearchNavigator && lastSearchNavigator.key ? getPreservedNavigatorState(lastSearchNavigator?.key) : undefined; + const lastSearchRoute = lastSearchNavigatorState?.routes.findLast((route) => route.name === SCREENS.SEARCH.ROOT); + + if (lastSearchRoute) { + const {q, ...rest} = lastSearchRoute.params as SearchFullscreenNavigatorParamList[typeof SCREENS.SEARCH.ROOT]; + const queryJSON = buildSearchQueryJSON(q); + if (queryJSON) { + const query = buildSearchQueryString(queryJSON); + Navigation.navigate( + ROUTES.SEARCH_ROOT.getRoute({ + query, + ...rest, + }), + ); + return; + } + } + + const flattenedMenuItems = typeMenuSections.flatMap((section) => section.menuItems); + const defaultActionableSearchQuery = + getDefaultActionableSearchMenuItem(flattenedMenuItems)?.searchQuery ?? flattenedMenuItems.at(0)?.searchQuery ?? typeMenuSections.at(0)?.menuItems.at(0)?.searchQuery; + + const savedSearchQuery = Object.values(savedSearches ?? {}).at(0)?.query; + const lastQueryFromOnyx = lastSearchParams?.queryJSON ? buildSearchQueryString(lastSearchParams.queryJSON) : undefined; + Navigation.navigate(ROUTES.SEARCH_ROOT.getRoute({query: lastQueryFromOnyx ?? defaultActionableSearchQuery ?? savedSearchQuery ?? buildCannedSearchQuery()})); + }); + }, [selectedTab, typeMenuSections, savedSearches, lastSearchParams?.queryJSON]); + + const navigateToSettings = useCallback(() => { + if (selectedTab === NAVIGATION_TABS.SETTINGS) { + return; + } + interceptAnonymousUser(() => { + const accountTabPayload = getAccountTabScreenToOpen(subscriptionPlan); + + if (isRoutePreloaded(NAVIGATORS.SETTINGS_SPLIT_NAVIGATOR)) { + // We use dispatch here because the correct screens and params are preloaded and set up in usePreloadFullScreenNavigators. + navigationRef.dispatch({type: CONST.NAVIGATION.ACTION_TYPE.PUSH, payload: {name: NAVIGATORS.SETTINGS_SPLIT_NAVIGATOR, params: accountTabPayload}}); + return; + } + navigationRef.dispatch(StackActions.push(NAVIGATORS.SETTINGS_SPLIT_NAVIGATOR, accountTabPayload)); + }); + }, [selectedTab, subscriptionPlan]); + + /** + * The settings tab is related to SettingsSplitNavigator and WorkspaceSplitNavigator. + * If the user opens this tab from another tab, it is necessary to check whether it has not been opened before. + * If so, all previously opened screens have be pushed to the navigation stack to maintain the order of screens within the tab. + * If the user clicks on the settings tab while on this tab, this button should go back to the previous screen within the tab. + */ + const showWorkspaces = useCallback(() => { + navigateToWorkspacesPage({shouldUseNarrowLayout, currentUserLogin, policy: lastViewedPolicy, domain: lastViewedDomain}); + }, [shouldUseNarrowLayout, currentUserLogin, lastViewedPolicy, lastViewedDomain]); + + const inboxAccessibilityState = useMemo(() => ({selected: selectedTab === NAVIGATION_TABS.INBOX}), [selectedTab]); + const searchAccessibilityState = useMemo(() => ({selected: selectedTab === NAVIGATION_TABS.SEARCH}), [selectedTab]); + const workspacesAccessibilityState = useMemo(() => ({selected: selectedTab === NAVIGATION_TABS.WORKSPACES}), [selectedTab]); + + if (!shouldUseNarrowLayout) { + return ( + <> + {shouldRenderDebugTabViewOnWideLayout && ( + + )} + + + + + + [styles.leftNavigationTabBarItem, hovered && styles.navigationTabBarItemHovered]} + sentryLabel="NavigationTabBar.Home" + > + {({hovered}) => ( + <> + + + + + {translate('common.home')} + + + )} + + [styles.leftNavigationTabBarItem, hovered && styles.navigationTabBarItemHovered]} + sentryLabel={CONST.SENTRY_LABEL.NAVIGATION_TAB_BAR.REPORTS} + > + {({hovered}) => ( + <> + + + + + {translate('common.reports')} + + + )} + + [styles.leftNavigationTabBarItem, hovered && styles.navigationTabBarItemHovered]} + sentryLabel={CONST.SENTRY_LABEL.NAVIGATION_TAB_BAR.INBOX} + > + {({hovered}) => ( + <> + + + {!!chatTabBrickRoad && ( + + )} + + + {translate('common.inbox')} + + + )} + + [styles.leftNavigationTabBarItem, hovered && styles.navigationTabBarItemHovered]} + sentryLabel={CONST.SENTRY_LABEL.NAVIGATION_TAB_BAR.WORKSPACES} + > + {({hovered}) => ( + <> + + + {!!workspacesTabIndicatorStatus && ( + + )} + + + {translate('common.workspacesTabTitle')} + + + )} + + + + + + + + + ); + } + + return ( + <> + {!!isDebugModeEnabled && ( + + )} + + + + + + + {translate('common.home')} + + + + + + + + {translate('common.reports')} + + + + + + {!!chatTabBrickRoad && ( + + )} + + + {translate('common.inbox')} + + + + + + {!!workspacesTabIndicatorStatus && } + + + {translate('common.workspacesTabTitle')} + + + + + + + + + {shouldShowFloatingButtons && ( + <> + + + + )} + + ); +} + +export default memo(HomeNavigationTabBar); diff --git a/src/components/Navigation/NavigationTabBar/InboxNavigationTabBar.tsx b/src/components/Navigation/NavigationTabBar/InboxNavigationTabBar.tsx new file mode 100644 index 000000000000..c6e746dffa61 --- /dev/null +++ b/src/components/Navigation/NavigationTabBar/InboxNavigationTabBar.tsx @@ -0,0 +1,542 @@ +import {findFocusedRoute, StackActions, useNavigationState} from '@react-navigation/native'; +import reportsSelector from '@selectors/Attributes'; +import React, {memo, useCallback, useEffect, useMemo, useState} from 'react'; +import {View} from 'react-native'; +import type {OnyxCollection} from 'react-native-onyx'; +import type {ValueOf} from 'type-fest'; +import FloatingCameraButton from '@components/FloatingCameraButton'; +import FloatingGPSButton from '@components/FloatingGPSButton'; +import Icon from '@components/Icon'; +import ImageSVG from '@components/ImageSVG'; +import DebugTabView from '@components/Navigation/DebugTabView'; +import {PressableWithFeedback} from '@components/Pressable'; +import Text from '@components/Text'; +import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; +import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; +import useLocalize from '@hooks/useLocalize'; +import useOnyx from '@hooks/useOnyx'; +import useResponsiveLayout from '@hooks/useResponsiveLayout'; +import useSearchTypeMenuSections from '@hooks/useSearchTypeMenuSections'; +import {useSidebarOrderedReports} from '@hooks/useSidebarOrderedReports'; +import useStyleUtils from '@hooks/useStyleUtils'; +import useSubscriptionPlan from '@hooks/useSubscriptionPlan'; +import useTheme from '@hooks/useTheme'; +import useThemeStyles from '@hooks/useThemeStyles'; +import useWorkspacesTabIndicatorStatus from '@hooks/useWorkspacesTabIndicatorStatus'; +import clearSelectedText from '@libs/clearSelectedText/clearSelectedText'; +import interceptAnonymousUser from '@libs/interceptAnonymousUser'; +import {getPreservedNavigatorState} from '@libs/Navigation/AppNavigator/createSplitNavigator/usePreserveNavigatorState'; +import getAccountTabScreenToOpen from '@libs/Navigation/helpers/getAccountTabScreenToOpen'; +import isRoutePreloaded from '@libs/Navigation/helpers/isRoutePreloaded'; +import navigateToWorkspacesPage, {getWorkspaceNavigationRouteState} from '@libs/Navigation/helpers/navigateToWorkspacesPage'; +import Navigation from '@libs/Navigation/Navigation'; +import {buildCannedSearchQuery, buildSearchQueryJSON, buildSearchQueryString} from '@libs/SearchQueryUtils'; +import {getDefaultActionableSearchMenuItem} from '@libs/SearchUIUtils'; +import {startSpan} from '@libs/telemetry/activeSpans'; +import type {BrickRoad} from '@libs/WorkspacesSettingsUtils'; +import {getChatTabBrickRoad} from '@libs/WorkspacesSettingsUtils'; +import navigationRef from '@navigation/navigationRef'; +import type {DomainSplitNavigatorParamList, RootNavigatorParamList, SearchFullscreenNavigatorParamList, State, WorkspaceSplitNavigatorParamList} from '@navigation/types'; +import NavigationTabBarAvatar from '@pages/inbox/sidebar/NavigationTabBarAvatar'; +import NavigationTabBarFloatingActionButton from '@pages/inbox/sidebar/NavigationTabBarFloatingActionButton'; +import variables from '@styles/variables'; +import CONST from '@src/CONST'; +import NAVIGATORS from '@src/NAVIGATORS'; +import ONYXKEYS from '@src/ONYXKEYS'; +import ROUTES from '@src/ROUTES'; +import SCREENS from '@src/SCREENS'; +import type {Domain, Policy} from '@src/types/onyx'; +import NAVIGATION_TABS from './NAVIGATION_TABS'; + +type NavigationTabBarProps = { + selectedTab: ValueOf; + isTopLevelBar?: boolean; + shouldShowFloatingButtons?: boolean; +}; + +function InboxNavigationTabBar({selectedTab, isTopLevelBar = false, shouldShowFloatingButtons = true}: NavigationTabBarProps) { + const theme = useTheme(); + const styles = useThemeStyles(); + + const getIconFill = useCallback( + (isSelected: boolean, isHovered: boolean) => { + if (isSelected) { + return theme.iconMenu; + } + if (isHovered) { + return theme.success; + } + return theme.icon; + }, + [theme], + ); + const {translate, preferredLocale} = useLocalize(); + const {indicatorColor: workspacesTabIndicatorColor, status: workspacesTabIndicatorStatus} = useWorkspacesTabIndicatorStatus(); + const {orderedReportIDs} = useSidebarOrderedReports(); + const [isDebugModeEnabled] = useOnyx(ONYXKEYS.IS_DEBUG_MODE_ENABLED, {canBeMissing: true}); + const [savedSearches] = useOnyx(ONYXKEYS.SAVED_SEARCHES, {canBeMissing: true}); + const navigationState = useNavigationState(findFocusedRoute); + const initialNavigationRouteState = getWorkspaceNavigationRouteState(); + const [lastWorkspacesTabNavigatorRoute, setLastWorkspacesTabNavigatorRoute] = useState(initialNavigationRouteState.lastWorkspacesTabNavigatorRoute); + const [workspacesTabState, setWorkspacesTabState] = useState(initialNavigationRouteState.workspacesTabState); + const params = workspacesTabState?.routes?.at(0)?.params as + | WorkspaceSplitNavigatorParamList[typeof SCREENS.WORKSPACE.INITIAL] + | DomainSplitNavigatorParamList[typeof SCREENS.DOMAIN.INITIAL]; + const {typeMenuSections} = useSearchTypeMenuSections(); + const subscriptionPlan = useSubscriptionPlan(); + const expensifyIcons = useMemoizedLazyExpensifyIcons(['ExpensifyAppIcon', 'Inbox', 'MoneySearch', 'Buildings']); + + const paramsPolicyID = params && 'policyID' in params ? params.policyID : undefined; + const paramsDomainAccountID = params && 'domainAccountID' in params ? params.domainAccountID : undefined; + + const lastViewedPolicySelector = useCallback( + (policies: OnyxCollection) => { + if (!lastWorkspacesTabNavigatorRoute || lastWorkspacesTabNavigatorRoute.name !== NAVIGATORS.WORKSPACE_SPLIT_NAVIGATOR || !paramsPolicyID) { + return undefined; + } + + return policies?.[`${ONYXKEYS.COLLECTION.POLICY}${paramsPolicyID}`]; + }, + [paramsPolicyID, lastWorkspacesTabNavigatorRoute], + ); + + const [lastViewedPolicy] = useOnyx( + ONYXKEYS.COLLECTION.POLICY, + { + canBeMissing: true, + selector: lastViewedPolicySelector, + }, + [navigationState], + ); + + const lastViewedDomainSelector = useCallback( + (domains: OnyxCollection) => { + if (!lastWorkspacesTabNavigatorRoute || lastWorkspacesTabNavigatorRoute.name !== NAVIGATORS.DOMAIN_SPLIT_NAVIGATOR || !paramsDomainAccountID) { + return undefined; + } + + return domains?.[`${ONYXKEYS.COLLECTION.DOMAIN}${paramsDomainAccountID}`]; + }, + [paramsDomainAccountID, lastWorkspacesTabNavigatorRoute], + ); + + const [lastViewedDomain] = useOnyx( + ONYXKEYS.COLLECTION.DOMAIN, + { + canBeMissing: true, + selector: lastViewedDomainSelector, + }, + [navigationState], + ); + + const [reportAttributes] = useOnyx(ONYXKEYS.DERIVED.REPORT_ATTRIBUTES, {selector: reportsSelector, canBeMissing: true}); + const {login: currentUserLogin} = useCurrentUserPersonalDetails(); + const {shouldUseNarrowLayout} = useResponsiveLayout(); + const [chatTabBrickRoad, setChatTabBrickRoad] = useState(undefined); + + const StyleUtils = useStyleUtils(); + + useEffect(() => { + const newWorkspacesTabState = getWorkspaceNavigationRouteState(); + const newLastRoute = newWorkspacesTabState.lastWorkspacesTabNavigatorRoute; + const newTabState = newWorkspacesTabState.workspacesTabState; + + setLastWorkspacesTabNavigatorRoute(newLastRoute); + setWorkspacesTabState(newTabState); + }, [navigationState]); + + // On a wide layout DebugTabView should be rendered only within the navigation tab bar displayed directly on screens. + const shouldRenderDebugTabViewOnWideLayout = !!isDebugModeEnabled && !isTopLevelBar; + + useEffect(() => { + setChatTabBrickRoad(getChatTabBrickRoad(orderedReportIDs, reportAttributes)); + }, [orderedReportIDs, reportAttributes]); + + const navigateToChats = useCallback(() => { + if (selectedTab === NAVIGATION_TABS.INBOX) { + return; + } + + startSpan(CONST.TELEMETRY.SPAN_NAVIGATE_TO_INBOX_TAB, { + name: CONST.TELEMETRY.SPAN_NAVIGATE_TO_INBOX_TAB, + op: CONST.TELEMETRY.SPAN_NAVIGATE_TO_INBOX_TAB, + }); + + if (!shouldUseNarrowLayout && isRoutePreloaded(NAVIGATORS.REPORTS_SPLIT_NAVIGATOR)) { + // We use dispatch here because the correct screens and params are preloaded and set up in usePreloadFullScreenNavigators. + navigationRef.dispatch(StackActions.push(NAVIGATORS.REPORTS_SPLIT_NAVIGATOR)); + return; + } + + Navigation.navigate(ROUTES.INBOX); + }, [selectedTab, shouldUseNarrowLayout]); + + const [lastSearchParams] = useOnyx(ONYXKEYS.REPORT_NAVIGATION_LAST_SEARCH_QUERY, {canBeMissing: true}); + + const navigateToSearch = useCallback(() => { + if (selectedTab === NAVIGATION_TABS.SEARCH) { + return; + } + clearSelectedText(); + interceptAnonymousUser(() => { + const parentSpan = startSpan(CONST.TELEMETRY.SPAN_NAVIGATE_TO_REPORTS_TAB, { + name: CONST.TELEMETRY.SPAN_NAVIGATE_TO_REPORTS_TAB, + op: CONST.TELEMETRY.SPAN_NAVIGATE_TO_REPORTS_TAB, + }); + + startSpan(CONST.TELEMETRY.SPAN_ON_LAYOUT_SKELETON_REPORTS, { + name: CONST.TELEMETRY.SPAN_ON_LAYOUT_SKELETON_REPORTS, + op: CONST.TELEMETRY.SPAN_ON_LAYOUT_SKELETON_REPORTS, + parentSpan, + }); + + const rootState = navigationRef.getRootState() as State; + const lastSearchNavigator = rootState.routes.findLast((route) => route.name === NAVIGATORS.SEARCH_FULLSCREEN_NAVIGATOR); + const lastSearchNavigatorState = lastSearchNavigator && lastSearchNavigator.key ? getPreservedNavigatorState(lastSearchNavigator?.key) : undefined; + const lastSearchRoute = lastSearchNavigatorState?.routes.findLast((route) => route.name === SCREENS.SEARCH.ROOT); + + if (lastSearchRoute) { + const {q, ...rest} = lastSearchRoute.params as SearchFullscreenNavigatorParamList[typeof SCREENS.SEARCH.ROOT]; + const queryJSON = buildSearchQueryJSON(q); + if (queryJSON) { + const query = buildSearchQueryString(queryJSON); + Navigation.navigate( + ROUTES.SEARCH_ROOT.getRoute({ + query, + ...rest, + }), + ); + return; + } + } + + const flattenedMenuItems = typeMenuSections.flatMap((section) => section.menuItems); + const defaultActionableSearchQuery = + getDefaultActionableSearchMenuItem(flattenedMenuItems)?.searchQuery ?? flattenedMenuItems.at(0)?.searchQuery ?? typeMenuSections.at(0)?.menuItems.at(0)?.searchQuery; + + const savedSearchQuery = Object.values(savedSearches ?? {}).at(0)?.query; + const lastQueryFromOnyx = lastSearchParams?.queryJSON ? buildSearchQueryString(lastSearchParams.queryJSON) : undefined; + Navigation.navigate(ROUTES.SEARCH_ROOT.getRoute({query: lastQueryFromOnyx ?? defaultActionableSearchQuery ?? savedSearchQuery ?? buildCannedSearchQuery()})); + }); + }, [selectedTab, typeMenuSections, savedSearches, lastSearchParams?.queryJSON]); + + const navigateToSettings = useCallback(() => { + if (selectedTab === NAVIGATION_TABS.SETTINGS) { + return; + } + interceptAnonymousUser(() => { + const accountTabPayload = getAccountTabScreenToOpen(subscriptionPlan); + + if (isRoutePreloaded(NAVIGATORS.SETTINGS_SPLIT_NAVIGATOR)) { + // We use dispatch here because the correct screens and params are preloaded and set up in usePreloadFullScreenNavigators. + navigationRef.dispatch({type: CONST.NAVIGATION.ACTION_TYPE.PUSH, payload: {name: NAVIGATORS.SETTINGS_SPLIT_NAVIGATOR, params: accountTabPayload}}); + return; + } + navigationRef.dispatch(StackActions.push(NAVIGATORS.SETTINGS_SPLIT_NAVIGATOR, accountTabPayload)); + }); + }, [selectedTab, subscriptionPlan]); + + /** + * The settings tab is related to SettingsSplitNavigator and WorkspaceSplitNavigator. + * If the user opens this tab from another tab, it is necessary to check whether it has not been opened before. + * If so, all previously opened screens have be pushed to the navigation stack to maintain the order of screens within the tab. + * If the user clicks on the settings tab while on this tab, this button should go back to the previous screen within the tab. + */ + const showWorkspaces = useCallback(() => { + navigateToWorkspacesPage({shouldUseNarrowLayout, currentUserLogin, policy: lastViewedPolicy, domain: lastViewedDomain}); + }, [shouldUseNarrowLayout, currentUserLogin, lastViewedPolicy, lastViewedDomain]); + + const inboxAccessibilityState = useMemo(() => ({selected: selectedTab === NAVIGATION_TABS.INBOX}), [selectedTab]); + const searchAccessibilityState = useMemo(() => ({selected: selectedTab === NAVIGATION_TABS.SEARCH}), [selectedTab]); + const workspacesAccessibilityState = useMemo(() => ({selected: selectedTab === NAVIGATION_TABS.WORKSPACES}), [selectedTab]); + + if (!shouldUseNarrowLayout) { + return ( + <> + {shouldRenderDebugTabViewOnWideLayout && ( + + )} + + + + + + [styles.leftNavigationTabBarItem, hovered && styles.navigationTabBarItemHovered]} + sentryLabel={CONST.SENTRY_LABEL.NAVIGATION_TAB_BAR.INBOX} + > + {({hovered}) => ( + <> + + + {!!chatTabBrickRoad && ( + + )} + + + {translate('common.inbox')} + + + )} + + [styles.leftNavigationTabBarItem, hovered && styles.navigationTabBarItemHovered]} + sentryLabel={CONST.SENTRY_LABEL.NAVIGATION_TAB_BAR.REPORTS} + > + {({hovered}) => ( + <> + + + + + {translate('common.reports')} + + + )} + + [styles.leftNavigationTabBarItem, hovered && styles.navigationTabBarItemHovered]} + sentryLabel={CONST.SENTRY_LABEL.NAVIGATION_TAB_BAR.WORKSPACES} + > + {({hovered}) => ( + <> + + + {!!workspacesTabIndicatorStatus && ( + + )} + + + {translate('common.workspacesTabTitle')} + + + )} + + + + + + + + + ); + } + + return ( + <> + {!!isDebugModeEnabled && ( + + )} + + + + + {!!chatTabBrickRoad && ( + + )} + + + {translate('common.inbox')} + + + + + + + + {translate('common.reports')} + + + + + + + + + {!!workspacesTabIndicatorStatus && } + + + {translate('common.workspacesTabTitle')} + + + + + {shouldShowFloatingButtons && ( + <> + + + + )} + + ); +} + +export default memo(InboxNavigationTabBar); diff --git a/src/components/Navigation/NavigationTabBar/NAVIGATION_TABS.ts b/src/components/Navigation/NavigationTabBar/NAVIGATION_TABS.ts index 254b6379fa7f..a870f4400c31 100644 --- a/src/components/Navigation/NavigationTabBar/NAVIGATION_TABS.ts +++ b/src/components/Navigation/NavigationTabBar/NAVIGATION_TABS.ts @@ -1,5 +1,6 @@ const NAVIGATION_TABS = { HOME: 'HOME', + INBOX: 'INBOX', SEARCH: 'SEARCH', WORKSPACES: 'WORKSPACES', SETTINGS: 'SETTINGS', diff --git a/src/components/Navigation/NavigationTabBar/index.tsx b/src/components/Navigation/NavigationTabBar/index.tsx index 6c53c2b1091a..39c9d82cc7a8 100644 --- a/src/components/Navigation/NavigationTabBar/index.tsx +++ b/src/components/Navigation/NavigationTabBar/index.tsx @@ -1,53 +1,10 @@ -import {findFocusedRoute, StackActions, useNavigationState} from '@react-navigation/native'; -import reportsSelector from '@selectors/Attributes'; -import React, {memo, useCallback, useEffect, useMemo, useState} from 'react'; -import {View} from 'react-native'; -import type {OnyxCollection} from 'react-native-onyx'; +import React, {memo} from 'react'; import type {ValueOf} from 'type-fest'; -import FloatingCameraButton from '@components/FloatingCameraButton'; -import FloatingGPSButton from '@components/FloatingGPSButton'; -import Icon from '@components/Icon'; -// import * as Expensicons from '@components/Icon/Expensicons'; -import ImageSVG from '@components/ImageSVG'; -import DebugTabView from '@components/Navigation/DebugTabView'; -import {PressableWithFeedback} from '@components/Pressable'; -import Text from '@components/Text'; -import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; -import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; -import useLocalize from '@hooks/useLocalize'; -import useOnyx from '@hooks/useOnyx'; -import useResponsiveLayout from '@hooks/useResponsiveLayout'; -import useSearchTypeMenuSections from '@hooks/useSearchTypeMenuSections'; -import {useSidebarOrderedReports} from '@hooks/useSidebarOrderedReports'; -import useStyleUtils from '@hooks/useStyleUtils'; -import useSubscriptionPlan from '@hooks/useSubscriptionPlan'; -import useTheme from '@hooks/useTheme'; -import useThemeStyles from '@hooks/useThemeStyles'; -import useWorkspacesTabIndicatorStatus from '@hooks/useWorkspacesTabIndicatorStatus'; -import clearSelectedText from '@libs/clearSelectedText/clearSelectedText'; -import interceptAnonymousUser from '@libs/interceptAnonymousUser'; -import {getPreservedNavigatorState} from '@libs/Navigation/AppNavigator/createSplitNavigator/usePreserveNavigatorState'; -import getAccountTabScreenToOpen from '@libs/Navigation/helpers/getAccountTabScreenToOpen'; -import isRoutePreloaded from '@libs/Navigation/helpers/isRoutePreloaded'; -import navigateToWorkspacesPage, {getWorkspaceNavigationRouteState} from '@libs/Navigation/helpers/navigateToWorkspacesPage'; -import Navigation from '@libs/Navigation/Navigation'; -import {buildCannedSearchQuery, buildSearchQueryJSON, buildSearchQueryString} from '@libs/SearchQueryUtils'; -import {getDefaultActionableSearchMenuItem} from '@libs/SearchUIUtils'; -import {startSpan} from '@libs/telemetry/activeSpans'; -import type {BrickRoad} from '@libs/WorkspacesSettingsUtils'; -import {getChatTabBrickRoad} from '@libs/WorkspacesSettingsUtils'; -import navigationRef from '@navigation/navigationRef'; -import type {DomainSplitNavigatorParamList, RootNavigatorParamList, SearchFullscreenNavigatorParamList, State, WorkspaceSplitNavigatorParamList} from '@navigation/types'; -import NavigationTabBarAvatar from '@pages/home/sidebar/NavigationTabBarAvatar'; -import NavigationTabBarFloatingActionButton from '@pages/home/sidebar/NavigationTabBarFloatingActionButton'; -import variables from '@styles/variables'; +import usePermissions from '@hooks/usePermissions'; import CONST from '@src/CONST'; -import NAVIGATORS from '@src/NAVIGATORS'; -import ONYXKEYS from '@src/ONYXKEYS'; -import ROUTES from '@src/ROUTES'; -import SCREENS from '@src/SCREENS'; -import type {Domain, Policy} from '@src/types/onyx'; -import NAVIGATION_TABS from './NAVIGATION_TABS'; +import HomeNavigationTabBar from './HomeNavigationTabBar'; +import InboxNavigationTabBar from './InboxNavigationTabBar'; +import type NAVIGATION_TABS from './NAVIGATION_TABS'; type NavigationTabBarProps = { selectedTab: ValueOf; @@ -56,487 +13,25 @@ type NavigationTabBarProps = { }; function NavigationTabBar({selectedTab, isTopLevelBar = false, shouldShowFloatingButtons = true}: NavigationTabBarProps) { - const theme = useTheme(); - const styles = useThemeStyles(); + const {isBetaEnabled} = usePermissions(); + const isNewDotHomeEnabled = isBetaEnabled(CONST.BETAS.NEW_DOT_HOME); - const getIconFill = useCallback( - (isSelected: boolean, isHovered: boolean) => { - if (isSelected) { - return theme.iconMenu; - } - if (isHovered) { - return theme.success; - } - return theme.icon; - }, - [theme], - ); - const {translate, preferredLocale} = useLocalize(); - const {indicatorColor: workspacesTabIndicatorColor, status: workspacesTabIndicatorStatus} = useWorkspacesTabIndicatorStatus(); - const {orderedReportIDs} = useSidebarOrderedReports(); - const [isDebugModeEnabled] = useOnyx(ONYXKEYS.IS_DEBUG_MODE_ENABLED, {canBeMissing: true}); - const [savedSearches] = useOnyx(ONYXKEYS.SAVED_SEARCHES, {canBeMissing: true}); - const navigationState = useNavigationState(findFocusedRoute); - const initialNavigationRouteState = getWorkspaceNavigationRouteState(); - const [lastWorkspacesTabNavigatorRoute, setLastWorkspacesTabNavigatorRoute] = useState(initialNavigationRouteState.lastWorkspacesTabNavigatorRoute); - const [workspacesTabState, setWorkspacesTabState] = useState(initialNavigationRouteState.workspacesTabState); - const params = workspacesTabState?.routes?.at(0)?.params as - | WorkspaceSplitNavigatorParamList[typeof SCREENS.WORKSPACE.INITIAL] - | DomainSplitNavigatorParamList[typeof SCREENS.DOMAIN.INITIAL]; - const {typeMenuSections} = useSearchTypeMenuSections(); - const subscriptionPlan = useSubscriptionPlan(); - const expensifyIcons = useMemoizedLazyExpensifyIcons(['ExpensifyAppIcon', 'Inbox', 'MoneySearch', 'Buildings']); - - const paramsPolicyID = params && 'policyID' in params ? params.policyID : undefined; - const paramsDomainAccountID = params && 'domainAccountID' in params ? params.domainAccountID : undefined; - - const lastViewedPolicySelector = useCallback( - (policies: OnyxCollection) => { - if (!lastWorkspacesTabNavigatorRoute || lastWorkspacesTabNavigatorRoute.name !== NAVIGATORS.WORKSPACE_SPLIT_NAVIGATOR || !paramsPolicyID) { - return undefined; - } - - return policies?.[`${ONYXKEYS.COLLECTION.POLICY}${paramsPolicyID}`]; - }, - [paramsPolicyID, lastWorkspacesTabNavigatorRoute], - ); - - const [lastViewedPolicy] = useOnyx( - ONYXKEYS.COLLECTION.POLICY, - { - canBeMissing: true, - selector: lastViewedPolicySelector, - }, - [navigationState], - ); - - const lastViewedDomainSelector = useCallback( - (domains: OnyxCollection) => { - if (!lastWorkspacesTabNavigatorRoute || lastWorkspacesTabNavigatorRoute.name !== NAVIGATORS.DOMAIN_SPLIT_NAVIGATOR || !paramsDomainAccountID) { - return undefined; - } - - return domains?.[`${ONYXKEYS.COLLECTION.DOMAIN}${paramsDomainAccountID}`]; - }, - [paramsDomainAccountID, lastWorkspacesTabNavigatorRoute], - ); - - const [lastViewedDomain] = useOnyx( - ONYXKEYS.COLLECTION.DOMAIN, - { - canBeMissing: true, - selector: lastViewedDomainSelector, - }, - [navigationState], - ); - - const [reportAttributes] = useOnyx(ONYXKEYS.DERIVED.REPORT_ATTRIBUTES, {selector: reportsSelector, canBeMissing: true}); - const {login: currentUserLogin} = useCurrentUserPersonalDetails(); - const {shouldUseNarrowLayout} = useResponsiveLayout(); - const [chatTabBrickRoad, setChatTabBrickRoad] = useState(undefined); - - const StyleUtils = useStyleUtils(); - - useEffect(() => { - const newWorkspacesTabState = getWorkspaceNavigationRouteState(); - const newLastRoute = newWorkspacesTabState.lastWorkspacesTabNavigatorRoute; - const newTabState = newWorkspacesTabState.workspacesTabState; - - setLastWorkspacesTabNavigatorRoute(newLastRoute); - setWorkspacesTabState(newTabState); - }, [navigationState]); - - // On a wide layout DebugTabView should be rendered only within the navigation tab bar displayed directly on screens. - const shouldRenderDebugTabViewOnWideLayout = !!isDebugModeEnabled && !isTopLevelBar; - - useEffect(() => { - setChatTabBrickRoad(getChatTabBrickRoad(orderedReportIDs, reportAttributes)); - }, [orderedReportIDs, reportAttributes]); - - const navigateToChats = useCallback(() => { - if (selectedTab === NAVIGATION_TABS.HOME) { - return; - } - - startSpan(CONST.TELEMETRY.SPAN_NAVIGATE_TO_INBOX_TAB, { - name: CONST.TELEMETRY.SPAN_NAVIGATE_TO_INBOX_TAB, - op: CONST.TELEMETRY.SPAN_NAVIGATE_TO_INBOX_TAB, - }); - - if (!shouldUseNarrowLayout && isRoutePreloaded(NAVIGATORS.REPORTS_SPLIT_NAVIGATOR)) { - // We use dispatch here because the correct screens and params are preloaded and set up in usePreloadFullScreenNavigators. - navigationRef.dispatch(StackActions.push(NAVIGATORS.REPORTS_SPLIT_NAVIGATOR)); - return; - } - - Navigation.navigate(ROUTES.HOME); - }, [selectedTab, shouldUseNarrowLayout]); - - const [lastSearchParams] = useOnyx(ONYXKEYS.REPORT_NAVIGATION_LAST_SEARCH_QUERY, {canBeMissing: true}); - - const navigateToSearch = useCallback(() => { - if (selectedTab === NAVIGATION_TABS.SEARCH) { - return; - } - clearSelectedText(); - interceptAnonymousUser(() => { - const parentSpan = startSpan(CONST.TELEMETRY.SPAN_NAVIGATE_TO_REPORTS_TAB, { - name: CONST.TELEMETRY.SPAN_NAVIGATE_TO_REPORTS_TAB, - op: CONST.TELEMETRY.SPAN_NAVIGATE_TO_REPORTS_TAB, - }); - - startSpan(CONST.TELEMETRY.SPAN_ON_LAYOUT_SKELETON_REPORTS, { - name: CONST.TELEMETRY.SPAN_ON_LAYOUT_SKELETON_REPORTS, - op: CONST.TELEMETRY.SPAN_ON_LAYOUT_SKELETON_REPORTS, - parentSpan, - }); - - const rootState = navigationRef.getRootState() as State; - const lastSearchNavigator = rootState.routes.findLast((route) => route.name === NAVIGATORS.SEARCH_FULLSCREEN_NAVIGATOR); - const lastSearchNavigatorState = lastSearchNavigator && lastSearchNavigator.key ? getPreservedNavigatorState(lastSearchNavigator?.key) : undefined; - const lastSearchRoute = lastSearchNavigatorState?.routes.findLast((route) => route.name === SCREENS.SEARCH.ROOT); - - if (lastSearchRoute) { - const {q, ...rest} = lastSearchRoute.params as SearchFullscreenNavigatorParamList[typeof SCREENS.SEARCH.ROOT]; - const queryJSON = buildSearchQueryJSON(q); - if (queryJSON) { - const query = buildSearchQueryString(queryJSON); - Navigation.navigate( - ROUTES.SEARCH_ROOT.getRoute({ - query, - ...rest, - }), - ); - return; - } - } - - const flattenedMenuItems = typeMenuSections.flatMap((section) => section.menuItems); - const defaultActionableSearchQuery = - getDefaultActionableSearchMenuItem(flattenedMenuItems)?.searchQuery ?? flattenedMenuItems.at(0)?.searchQuery ?? typeMenuSections.at(0)?.menuItems.at(0)?.searchQuery; - - const savedSearchQuery = Object.values(savedSearches ?? {}).at(0)?.query; - const lastQueryFromOnyx = lastSearchParams?.queryJSON ? buildSearchQueryString(lastSearchParams.queryJSON) : undefined; - Navigation.navigate(ROUTES.SEARCH_ROOT.getRoute({query: lastQueryFromOnyx ?? defaultActionableSearchQuery ?? savedSearchQuery ?? buildCannedSearchQuery()})); - }); - }, [selectedTab, typeMenuSections, savedSearches, lastSearchParams?.queryJSON]); - - const navigateToSettings = useCallback(() => { - if (selectedTab === NAVIGATION_TABS.SETTINGS) { - return; - } - interceptAnonymousUser(() => { - const accountTabPayload = getAccountTabScreenToOpen(subscriptionPlan); - - if (isRoutePreloaded(NAVIGATORS.SETTINGS_SPLIT_NAVIGATOR)) { - // We use dispatch here because the correct screens and params are preloaded and set up in usePreloadFullScreenNavigators. - navigationRef.dispatch({type: CONST.NAVIGATION.ACTION_TYPE.PUSH, payload: {name: NAVIGATORS.SETTINGS_SPLIT_NAVIGATOR, params: accountTabPayload}}); - return; - } - navigationRef.dispatch(StackActions.push(NAVIGATORS.SETTINGS_SPLIT_NAVIGATOR, accountTabPayload)); - }); - }, [selectedTab, subscriptionPlan]); - - /** - * The settings tab is related to SettingsSplitNavigator and WorkspaceSplitNavigator. - * If the user opens this tab from another tab, it is necessary to check whether it has not been opened before. - * If so, all previously opened screens have be pushed to the navigation stack to maintain the order of screens within the tab. - * If the user clicks on the settings tab while on this tab, this button should go back to the previous screen within the tab. - */ - const showWorkspaces = useCallback(() => { - navigateToWorkspacesPage({shouldUseNarrowLayout, currentUserLogin, policy: lastViewedPolicy, domain: lastViewedDomain}); - }, [shouldUseNarrowLayout, currentUserLogin, lastViewedPolicy, lastViewedDomain]); - - const inboxAccessibilityState = useMemo(() => ({selected: selectedTab === NAVIGATION_TABS.HOME}), [selectedTab]); - const searchAccessibilityState = useMemo(() => ({selected: selectedTab === NAVIGATION_TABS.SEARCH}), [selectedTab]); - const workspacesAccessibilityState = useMemo(() => ({selected: selectedTab === NAVIGATION_TABS.WORKSPACES}), [selectedTab]); - - if (!shouldUseNarrowLayout) { + if (isNewDotHomeEnabled) { return ( - <> - {shouldRenderDebugTabViewOnWideLayout && ( - - )} - - - - - - [styles.leftNavigationTabBarItem, hovered && styles.navigationTabBarItemHovered]} - sentryLabel={CONST.SENTRY_LABEL.NAVIGATION_TAB_BAR.INBOX} - > - {({hovered}) => ( - <> - - - {!!chatTabBrickRoad && ( - - )} - - - {translate('common.inbox')} - - - )} - - [styles.leftNavigationTabBarItem, hovered && styles.navigationTabBarItemHovered]} - sentryLabel={CONST.SENTRY_LABEL.NAVIGATION_TAB_BAR.REPORTS} - > - {({hovered}) => ( - <> - - - - - {translate('common.reports')} - - - )} - - [styles.leftNavigationTabBarItem, hovered && styles.navigationTabBarItemHovered]} - sentryLabel={CONST.SENTRY_LABEL.NAVIGATION_TAB_BAR.WORKSPACES} - > - {({hovered}) => ( - <> - - - {!!workspacesTabIndicatorStatus && ( - - )} - - - {translate('common.workspacesTabTitle')} - - - )} - - - - - - - - + ); } return ( - <> - {!!isDebugModeEnabled && ( - - )} - - - - - {!!chatTabBrickRoad && ( - - )} - - - {translate('common.inbox')} - - - - - - - - {translate('common.reports')} - - - - - - - - - {!!workspacesTabIndicatorStatus && } - - - {translate('common.workspacesTabTitle')} - - - - - {shouldShowFloatingButtons && ( - <> - - - - )} - + ); } diff --git a/src/components/Navigation/TopBar.tsx b/src/components/Navigation/TopBar.tsx index 71e7a9aa5f50..c84f90722521 100644 --- a/src/components/Navigation/TopBar.tsx +++ b/src/components/Navigation/TopBar.tsx @@ -11,7 +11,7 @@ import useLoadingBarVisibility from '@hooks/useLoadingBarVisibility'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; import useThemeStyles from '@hooks/useThemeStyles'; -import SignInButton from '@pages/home/sidebar/SignInButton'; +import SignInButton from '@pages/inbox/sidebar/SignInButton'; import {isAnonymousUser as isAnonymousUserUtil} from '@userActions/Session'; import ONYXKEYS from '@src/ONYXKEYS'; import type {Session} from '@src/types/onyx'; diff --git a/src/components/Navigation/TopLevelNavigationTabBar/SCREENS_WITH_NAVIGATION_TAB_BAR.ts b/src/components/Navigation/TopLevelNavigationTabBar/SCREENS_WITH_NAVIGATION_TAB_BAR.ts index c5fd98bac153..5976defa8e19 100644 --- a/src/components/Navigation/TopLevelNavigationTabBar/SCREENS_WITH_NAVIGATION_TAB_BAR.ts +++ b/src/components/Navigation/TopLevelNavigationTabBar/SCREENS_WITH_NAVIGATION_TAB_BAR.ts @@ -2,6 +2,6 @@ import {SIDEBAR_TO_SPLIT} from '@libs/Navigation/linkingConfig/RELATIONS'; import NAVIGATORS from '@src/NAVIGATORS'; import SCREENS from '@src/SCREENS'; -const SCREENS_WITH_NAVIGATION_TAB_BAR = [...Object.keys(SIDEBAR_TO_SPLIT), NAVIGATORS.SEARCH_FULLSCREEN_NAVIGATOR, SCREENS.SEARCH.ROOT, SCREENS.WORKSPACES_LIST]; +const SCREENS_WITH_NAVIGATION_TAB_BAR = [...Object.keys(SIDEBAR_TO_SPLIT), NAVIGATORS.SEARCH_FULLSCREEN_NAVIGATOR, SCREENS.SEARCH.ROOT, SCREENS.WORKSPACES_LIST, SCREENS.HOME]; export default SCREENS_WITH_NAVIGATION_TAB_BAR; diff --git a/src/components/PriorityModeController.tsx b/src/components/PriorityModeController.tsx index d1912aa0d325..a29364ee48be 100644 --- a/src/components/PriorityModeController.tsx +++ b/src/components/PriorityModeController.tsx @@ -77,7 +77,7 @@ export default function PriorityModeController() { // We wait for the user to navigate back to the home screen before triggering this switch const isNarrowLayout = getIsNarrowLayout(); - if ((isNarrowLayout && currentRouteName !== SCREENS.HOME) || (!isNarrowLayout && currentRouteName !== SCREENS.REPORT)) { + if ((isNarrowLayout && currentRouteName !== SCREENS.INBOX) || (!isNarrowLayout && currentRouteName !== SCREENS.REPORT)) { Log.info("[PriorityModeController] Not switching user to focus mode as they aren't on the home screen", false, {validReportCount, currentRouteName}); return; } diff --git a/src/components/Reactions/AddReactionBubble.tsx b/src/components/Reactions/AddReactionBubble.tsx index 773076f46ffc..bcf856f90252 100644 --- a/src/components/Reactions/AddReactionBubble.tsx +++ b/src/components/Reactions/AddReactionBubble.tsx @@ -10,7 +10,7 @@ import useLocalize from '@hooks/useLocalize'; import useStyleUtils from '@hooks/useStyleUtils'; import useThemeStyles from '@hooks/useThemeStyles'; import getButtonState from '@libs/getButtonState'; -import {contextMenuRef} from '@pages/home/report/ContextMenu/ReportActionContextMenu'; +import {contextMenuRef} from '@pages/inbox/report/ContextMenu/ReportActionContextMenu'; import variables from '@styles/variables'; import {emojiPickerRef, resetEmojiPopoverAnchor, showEmojiPicker} from '@userActions/EmojiPickerAction'; import type {AnchorOrigin} from '@userActions/EmojiPickerAction'; diff --git a/src/components/Reactions/EmojiReactionBubble.tsx b/src/components/Reactions/EmojiReactionBubble.tsx index 141fbca3002d..41028c8eafe7 100644 --- a/src/components/Reactions/EmojiReactionBubble.tsx +++ b/src/components/Reactions/EmojiReactionBubble.tsx @@ -5,7 +5,7 @@ import Text from '@components/Text'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useStyleUtils from '@hooks/useStyleUtils'; import useThemeStyles from '@hooks/useThemeStyles'; -import type {ReactionListEvent} from '@pages/home/ReportScreenContext'; +import type {ReactionListEvent} from '@pages/inbox/ReportScreenContext'; import CONST from '@src/CONST'; type EmojiReactionBubbleProps = { diff --git a/src/components/Reactions/QuickEmojiReactions/index.tsx b/src/components/Reactions/QuickEmojiReactions/index.tsx index 661338082748..b8e28ee930de 100644 --- a/src/components/Reactions/QuickEmojiReactions/index.tsx +++ b/src/components/Reactions/QuickEmojiReactions/index.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import {contextMenuRef} from '@pages/home/report/ContextMenu/ReportActionContextMenu'; +import {contextMenuRef} from '@pages/inbox/report/ContextMenu/ReportActionContextMenu'; import CONST from '@src/CONST'; import BaseQuickEmojiReactions from './BaseQuickEmojiReactions'; import type {OpenPickerCallback, QuickEmojiReactionsProps} from './types'; diff --git a/src/components/Reactions/ReportActionItemEmojiReactions.tsx b/src/components/Reactions/ReportActionItemEmojiReactions.tsx index b17837826644..bc8bbb28f178 100644 --- a/src/components/Reactions/ReportActionItemEmojiReactions.tsx +++ b/src/components/Reactions/ReportActionItemEmojiReactions.tsx @@ -10,8 +10,8 @@ import type {WithCurrentUserPersonalDetailsProps} from '@components/withCurrentU import useOnyx from '@hooks/useOnyx'; import useThemeStyles from '@hooks/useThemeStyles'; import {getEmojiReactionDetails, getLocalizedEmojiName} from '@libs/EmojiUtils'; -import {ReactionListContext} from '@pages/home/ReportScreenContext'; -import type {ReactionListAnchor, ReactionListEvent} from '@pages/home/ReportScreenContext'; +import {ReactionListContext} from '@pages/inbox/ReportScreenContext'; +import type {ReactionListAnchor, ReactionListEvent} from '@pages/inbox/ReportScreenContext'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type {Locale, ReportAction, ReportActionReactions} from '@src/types/onyx'; diff --git a/src/components/RecordTroubleshootDataToolMenu/BaseRecordTroubleshootDataToolMenu.tsx b/src/components/RecordTroubleshootDataToolMenu/BaseRecordTroubleshootDataToolMenu.tsx index ca4b46d94a9a..7e8462ec9c56 100644 --- a/src/components/RecordTroubleshootDataToolMenu/BaseRecordTroubleshootDataToolMenu.tsx +++ b/src/components/RecordTroubleshootDataToolMenu/BaseRecordTroubleshootDataToolMenu.tsx @@ -2,7 +2,6 @@ import type JSZip from 'jszip'; import type {RefObject} from 'react'; import React, {useEffect, useState} from 'react'; import {Alert} from 'react-native'; -import DeviceInfo from 'react-native-device-info'; import Button from '@components/Button'; import Switch from '@components/Switch'; import TestToolRow from '@components/TestToolRow'; @@ -14,6 +13,7 @@ import {cleanupAfterDisable, disableRecording, enableRecording, stopProfilingAnd import type {ProfilingData} from '@libs/actions/Troubleshoot'; import {parseStringifiedMessages} from '@libs/Console'; import getPlatform from '@libs/getPlatform'; +import getMemoryInfo from '@libs/telemetry/getMemoryInfo'; import CONFIG from '@src/CONFIG'; import ONYXKEYS from '@src/ONYXKEYS'; import type {Log as OnyxLog} from '@src/types/onyx'; @@ -49,7 +49,7 @@ type BaseRecordTroubleshootDataToolMenuProps = { displayPath?: string; }; -function formatBytes(bytes: number, decimals = 2) { +function formatBytes(bytes: number, decimals = 2): string { if (!+bytes) { return '0 Bytes'; } @@ -59,8 +59,9 @@ function formatBytes(bytes: number, decimals = 2) { const sizes = ['Bytes', 'KiB', 'MiB', 'GiB']; const i = Math.floor(Math.log(bytes) / Math.log(k)); + const sizeIndex = Math.min(i, sizes.length - 1); - return `${parseFloat((bytes / k ** i).toFixed(dm))} ${sizes.at(i)}`; + return `${parseFloat((bytes / k ** sizeIndex).toFixed(dm))} ${sizes.at(sizeIndex)}`; } // WARNING: When changing this name make sure that the "scripts/symbolicate-profile.ts" script is still working! @@ -86,13 +87,13 @@ function BaseRecordTroubleshootDataToolMenu({ const [profileTracePath, setProfileTracePath] = useState(); const getAppInfo = async (profilingData: ProfilingData) => { - const [totalMemory, usedMemory] = await Promise.all([DeviceInfo.getTotalMemory(), DeviceInfo.getUsedMemory()]); + const memoryInfo = await getMemoryInfo(); return JSON.stringify({ appVersion: pkg.version, environment: CONFIG.ENVIRONMENT, platform: getPlatform(), - totalMemory: formatBytes(totalMemory, 2), - usedMemory: formatBytes(usedMemory, 2), + totalMemory: memoryInfo.totalMemoryBytes !== null ? formatBytes(memoryInfo.totalMemoryBytes, 2) : null, + usedMemory: memoryInfo.usedMemoryBytes !== null ? formatBytes(memoryInfo.usedMemoryBytes, 2) : null, memoizeStats: profilingData.memoizeStats, performance: profilingData.performanceMeasures, }); diff --git a/src/components/ReportActionItem/ExportWithDropdownMenu.tsx b/src/components/ReportActionItem/ExportWithDropdownMenu.tsx index ac64d50c1f1f..0ddf931bb40b 100644 --- a/src/components/ReportActionItem/ExportWithDropdownMenu.tsx +++ b/src/components/ReportActionItem/ExportWithDropdownMenu.tsx @@ -13,7 +13,7 @@ import useThemeStyles from '@hooks/useThemeStyles'; import {savePreferredExportMethod as savePreferredExportMethodUtils} from '@libs/actions/Policy/Policy'; import {exportToIntegration, markAsManuallyExported} from '@libs/actions/Report'; import {canBeExported as canBeExportedUtils, getIntegrationIcon, isExported as isExportedUtils} from '@libs/ReportUtils'; -import type {ExportType} from '@pages/home/report/ReportDetailsExportPage'; +import type {ExportType} from '@pages/inbox/report/ReportDetailsExportPage'; import variables from '@styles/variables'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; diff --git a/src/components/ReportActionItem/MoneyReportView.tsx b/src/components/ReportActionItem/MoneyReportView.tsx index 70e260c4ec45..b484b58de41b 100644 --- a/src/components/ReportActionItem/MoneyReportView.tsx +++ b/src/components/ReportActionItem/MoneyReportView.tsx @@ -36,7 +36,7 @@ import { isSettled as isSettledReportUtils, shouldHideSingleReportField, } from '@libs/ReportUtils'; -import AnimatedEmptyStateBackground from '@pages/home/report/AnimatedEmptyStateBackground'; +import AnimatedEmptyStateBackground from '@pages/inbox/report/AnimatedEmptyStateBackground'; import variables from '@styles/variables'; import CONST from '@src/CONST'; import {clearReportFieldKeyErrors} from '@src/libs/actions/Report'; diff --git a/src/components/ReportActionItem/MoneyRequestAction.tsx b/src/components/ReportActionItem/MoneyRequestAction.tsx index f13ec3ecd32c..758be23528a0 100644 --- a/src/components/ReportActionItem/MoneyRequestAction.tsx +++ b/src/components/ReportActionItem/MoneyRequestAction.tsx @@ -23,9 +23,9 @@ import { isSplitBillAction as isSplitBillActionReportActionsUtils, isTrackExpenseAction as isTrackExpenseActionReportActionsUtils, } from '@libs/ReportActionsUtils'; -import type {ContextMenuAnchor} from '@pages/home/report/ContextMenu/ReportActionContextMenu'; -import {contextMenuRef} from '@pages/home/report/ContextMenu/ReportActionContextMenu'; -import ReportActionItemContext from '@pages/home/report/ReportActionItemContext'; +import type {ContextMenuAnchor} from '@pages/inbox/report/ContextMenu/ReportActionContextMenu'; +import {contextMenuRef} from '@pages/inbox/report/ContextMenu/ReportActionContextMenu'; +import ReportActionItemContext from '@pages/inbox/report/ReportActionItemContext'; import CONST from '@src/CONST'; import type {TranslationPaths} from '@src/languages/types'; import ONYXKEYS from '@src/ONYXKEYS'; diff --git a/src/components/ReportActionItem/MoneyRequestReportPreview/index.tsx b/src/components/ReportActionItem/MoneyRequestReportPreview/index.tsx index 957cc1db45f8..918ebb9955a1 100644 --- a/src/components/ReportActionItem/MoneyRequestReportPreview/index.tsx +++ b/src/components/ReportActionItem/MoneyRequestReportPreview/index.tsx @@ -13,7 +13,7 @@ import {getIOUActionForReportID, isSplitBillAction as isSplitBillActionReportAct import {isIOUReport} from '@libs/ReportUtils'; import {startSpan} from '@libs/telemetry/activeSpans'; import Navigation from '@navigation/Navigation'; -import {contextMenuRef} from '@pages/home/report/ContextMenu/ReportActionContextMenu'; +import {contextMenuRef} from '@pages/inbox/report/ContextMenu/ReportActionContextMenu'; import Timing from '@userActions/Timing'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; diff --git a/src/components/ReportActionItem/MoneyRequestReportPreview/types.ts b/src/components/ReportActionItem/MoneyRequestReportPreview/types.ts index 0fb20456c25c..56eb8b73a0c6 100644 --- a/src/components/ReportActionItem/MoneyRequestReportPreview/types.ts +++ b/src/components/ReportActionItem/MoneyRequestReportPreview/types.ts @@ -2,7 +2,7 @@ import type {LayoutChangeEvent, ListRenderItem, StyleProp, ViewStyle} from 'reac import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; import type {TransactionPreviewStyleType} from '@components/ReportActionItem/TransactionPreview/types'; import type {ForwardedFSClassProps} from '@libs/Fullstory/types'; -import type {ContextMenuAnchor} from '@pages/home/report/ContextMenu/ReportActionContextMenu'; +import type {ContextMenuAnchor} from '@pages/inbox/report/ContextMenu/ReportActionContextMenu'; import type {PersonalDetails, Policy, Report, ReportAction, Transaction, TransactionViolations} from '@src/types/onyx'; type TransactionPreviewCarouselStyle = { diff --git a/src/components/ReportActionItem/MoneyRequestView.tsx b/src/components/ReportActionItem/MoneyRequestView.tsx index 3391bb538dbb..d6603b945a84 100644 --- a/src/components/ReportActionItem/MoneyRequestView.tsx +++ b/src/components/ReportActionItem/MoneyRequestView.tsx @@ -103,7 +103,7 @@ import { } from '@libs/TransactionUtils'; import ViolationsUtils from '@libs/Violations/ViolationsUtils'; import Navigation from '@navigation/Navigation'; -import AnimatedEmptyStateBackground from '@pages/home/report/AnimatedEmptyStateBackground'; +import AnimatedEmptyStateBackground from '@pages/inbox/report/AnimatedEmptyStateBackground'; import CONST from '@src/CONST'; import type {TranslationPaths} from '@src/languages/types'; import ONYXKEYS from '@src/ONYXKEYS'; diff --git a/src/components/ReportActionItem/TaskPreview.tsx b/src/components/ReportActionItem/TaskPreview.tsx index 4e98d9af4919..0793670e39ea 100644 --- a/src/components/ReportActionItem/TaskPreview.tsx +++ b/src/components/ReportActionItem/TaskPreview.tsx @@ -30,7 +30,7 @@ import getButtonState from '@libs/getButtonState'; import Navigation from '@libs/Navigation/Navigation'; import Parser from '@libs/Parser'; import {isCanceledTaskReport, isOpenTaskReport, isReportManager} from '@libs/ReportUtils'; -import type {ContextMenuAnchor} from '@pages/home/report/ContextMenu/ReportActionContextMenu'; +import type {ContextMenuAnchor} from '@pages/inbox/report/ContextMenu/ReportActionContextMenu'; import CONST from '@src/CONST'; import ROUTES from '@src/ROUTES'; import type {Report, ReportAction} from '@src/types/onyx'; diff --git a/src/components/ReportActionItem/TransactionPreview/types.ts b/src/components/ReportActionItem/TransactionPreview/types.ts index 886dcf25a185..daefd011c87b 100644 --- a/src/components/ReportActionItem/TransactionPreview/types.ts +++ b/src/components/ReportActionItem/TransactionPreview/types.ts @@ -1,6 +1,6 @@ import type {GestureResponderEvent, StyleProp, ViewStyle} from 'react-native'; import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; -import type {ContextMenuAnchor} from '@pages/home/report/ContextMenu/ReportActionContextMenu'; +import type {ContextMenuAnchor} from '@pages/inbox/report/ContextMenu/ReportActionContextMenu'; import type {PersonalDetailsList, Report, ReportAction, Transaction, TransactionViolations} from '@src/types/onyx'; import type {Errors} from '@src/types/onyx/OnyxCommon'; diff --git a/src/components/ReportActionItem/TripRoomPreview.tsx b/src/components/ReportActionItem/TripRoomPreview.tsx index 0677e62e8aee..a115e968bea6 100644 --- a/src/components/ReportActionItem/TripRoomPreview.tsx +++ b/src/components/ReportActionItem/TripRoomPreview.tsx @@ -22,7 +22,7 @@ import {canUseTouchScreen} from '@libs/DeviceCapabilities'; import Navigation from '@libs/Navigation/Navigation'; import type {ReservationData} from '@libs/TripReservationUtils'; import {getReservationsFromTripReport, getTripReservationIcon, getTripTotal} from '@libs/TripReservationUtils'; -import type {ContextMenuAnchor} from '@pages/home/report/ContextMenu/ReportActionContextMenu'; +import type {ContextMenuAnchor} from '@pages/inbox/report/ContextMenu/ReportActionContextMenu'; import variables from '@styles/variables'; import CONST from '@src/CONST'; import ROUTES from '@src/ROUTES'; diff --git a/src/components/ReportWelcomeText.tsx b/src/components/ReportWelcomeText.tsx index 332d6cf56b6a..0278ccc13542 100644 --- a/src/components/ReportWelcomeText.tsx +++ b/src/components/ReportWelcomeText.tsx @@ -15,6 +15,7 @@ import { getParticipantsAccountIDsForDisplay, getPolicyName, isChatRoom as isChatRoomReportUtils, + isConciergeChatReport, isInvoiceRoom as isInvoiceRoomReportUtils, isPolicyExpenseChat as isPolicyExpenseChatReportUtils, isSelfDM as isSelfDMReportUtils, @@ -58,6 +59,8 @@ function ReportWelcomeText({report, policy}: ReportWelcomeTextProps) { // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing const [reportMetadata] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_METADATA}${report?.reportID || undefined}`, {canBeMissing: true}); const isReportArchived = useReportIsArchived(report?.reportID); + const [conciergeReportID] = useOnyx(ONYXKEYS.CONCIERGE_REPORT_ID, {canBeMissing: true}); + const isConciergeChat = isConciergeChatReport(report, conciergeReportID); const isChatRoom = isChatRoomReportUtils(report); const isSelfDM = isSelfDMReportUtils(report); const isInvoiceRoom = isInvoiceRoomReportUtils(report); @@ -93,7 +96,9 @@ function ReportWelcomeText({report, policy}: ReportWelcomeTextProps) { const reportDetailsLink = report?.reportID ? `${environmentURL}/${ROUTES.REPORT_WITH_ID_DETAILS.getRoute(report.reportID, Navigation.getReportRHPActiveRoute())}` : ''; let welcomeHeroText = translate('reportActionsView.sayHello'); - if (isInvoiceRoom) { + if (isConciergeChat) { + welcomeHeroText = translate('reportActionsView.askMeAnything'); + } else if (isInvoiceRoom) { welcomeHeroText = translate('reportActionsView.sayHello'); } else if (isChatRoom) { welcomeHeroText = translate('reportActionsView.welcomeToRoom', {roomName: reportName}); diff --git a/src/components/Rule/RuleBooleanBase.tsx b/src/components/Rule/RuleBooleanBase.tsx index 1e06c4fa3137..55b8b88517ac 100644 --- a/src/components/Rule/RuleBooleanBase.tsx +++ b/src/components/Rule/RuleBooleanBase.tsx @@ -9,45 +9,54 @@ import type {ListItem} from '@components/SelectionList/ListItem/types'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; import useThemeStyles from '@hooks/useThemeStyles'; -import {updateDraftRule} from '@libs/actions/User'; -import Navigation from '@libs/Navigation/Navigation'; import CONST from '@src/CONST'; import type {TranslationPaths} from '@src/languages/types'; -import ONYXKEYS from '@src/ONYXKEYS'; -import ROUTES from '@src/ROUTES'; -import type {InputID} from '@src/types/form/ExpenseRuleForm'; +import type {OnyxFormKey} from '@src/ONYXKEYS'; import RuleNotFoundPageWrapper from './RuleNotFoundPageWrapper'; type BooleanFilterItem = ListItem & { value: ValueOf; }; -type RuleBooleanBasePageProps = { - /** The key from boolean-based InputID */ - fieldID: InputID; +type RuleBooleanBaseProps = { + /** The field ID from the form */ + fieldID: string; /** The translation key for the page title */ titleKey: TranslationPaths; - /** The rule identifier */ + /** The form ID to read from Onyx */ + formID: OnyxFormKey; + + /** Callback when a value is selected - receives boolean for merchant rules, string for personal rules */ + onSelect: (fieldID: string, value: boolean | 'true' | 'false' | null) => void; + + /** Callback to go back */ + onBack: () => void; + + /** Optional hash for rule not found validation */ hash?: string; + + /** Whether to use string values ('true'/'false') instead of boolean values (for ExpenseRuleForm compatibility) */ + useStringValues?: boolean; }; const booleanValues = Object.values(CONST.SEARCH.BOOLEAN); -function RuleBooleanBasePage({fieldID, titleKey, hash}: RuleBooleanBasePageProps) { +function RuleBooleanBase({fieldID, titleKey, formID, onSelect, onBack, hash, useStringValues = false}: RuleBooleanBaseProps) { const {translate} = useLocalize(); - const [form] = useOnyx(ONYXKEYS.FORMS.EXPENSE_RULE_FORM, {canBeMissing: true}); + const [form] = useOnyx(formID, {canBeMissing: true}); const styles = useThemeStyles(); - const selectedItem = - booleanValues.find((value) => { - if (!form?.[fieldID]) { - return false; - } - const booleanValue = form[fieldID] === 'true' ? CONST.SEARCH.BOOLEAN.YES : CONST.SEARCH.BOOLEAN.NO; - return booleanValue === value; - }) ?? null; + const formValue = (form as Record)?.[fieldID]; + + let selectedItem = null; + if (formValue !== undefined) { + // Handle both string ('true'/'false') and boolean (true/false) values + const isTruthy = useStringValues ? formValue === 'true' : formValue === true; + const booleanValue = isTruthy ? CONST.SEARCH.BOOLEAN.YES : CONST.SEARCH.BOOLEAN.NO; + selectedItem = booleanValues.find((value) => booleanValue === value) ?? null; + } const items = booleanValues.map((value) => ({ value, @@ -56,34 +65,33 @@ function RuleBooleanBasePage({fieldID, titleKey, hash}: RuleBooleanBasePageProps isSelected: selectedItem === value, })); - const goBack = () => { - Navigation.goBack(hash ? ROUTES.SETTINGS_RULES_EDIT.getRoute(hash) : ROUTES.SETTINGS_RULES_ADD.getRoute()); - }; - const onSelectItem = (selectedValue: BooleanFilterItem) => { - const newValue = selectedValue.isSelected ? null : selectedValue.value; - let value = ''; - if (newValue === CONST.SEARCH.BOOLEAN.YES) { - value = 'true'; - } else if (newValue === CONST.SEARCH.BOOLEAN.NO) { - value = 'false'; + // If clicking on already-selected item, unselect it (set to undefined) + if (selectedValue.isSelected) { + onSelect(fieldID, null); + return; + } + const isYes = selectedValue.value === CONST.SEARCH.BOOLEAN.YES; + let value: boolean | 'true' | 'false'; + if (useStringValues) { + value = isYes ? 'true' : 'false'; + } else { + value = isYes; } - updateDraftRule({[fieldID]: value}); - goBack(); + onSelect(fieldID, value); }; return ( void; + + /** Callback to go back */ + onBack: () => void; + + /** The route to navigate back to */ + backToRoute: Route; + + /** Optional hash for rule not found validation */ + hash?: string; +}; + +function RuleSelectionBase({titleKey, testID, selectedItem, items, onSave, onBack, backToRoute, hash}: RuleSelectionBaseProps) { + const styles = useThemeStyles(); + const {translate} = useLocalize(); + + return ( + + + + + + + + + ); +} + +export default RuleSelectionBase; diff --git a/src/components/Rule/RuleTextBase.tsx b/src/components/Rule/RuleTextBase.tsx index 9775aa2027c2..ba7f333d8a66 100644 --- a/src/components/Rule/RuleTextBase.tsx +++ b/src/components/Rule/RuleTextBase.tsx @@ -4,20 +4,14 @@ import HeaderWithBackButton from '@components/HeaderWithBackButton'; import ScreenWrapper from '@components/ScreenWrapper'; import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; -import {updateDraftRule} from '@libs/actions/User'; -import Navigation from '@libs/Navigation/Navigation'; -import CONST from '@src/CONST'; import type {TranslationPaths} from '@src/languages/types'; -import type ONYXKEYS from '@src/ONYXKEYS'; -import ROUTES from '@src/ROUTES'; -import type {InputID} from '@src/types/form/ExpenseRuleForm'; +import type {OnyxFormKey} from '@src/ONYXKEYS'; import RuleNotFoundPageWrapper from './RuleNotFoundPageWrapper'; import TextBase from './TextBase'; -// Text-based field IDs that accept string input -type RuleTextBaseProps = { +type RuleTextBaseProps = { /** The key from text-based InputID */ - fieldID: InputID; + fieldID: string; /** The translation key for the page title and input label if labelKey is missing */ titleKey: TranslationPaths; @@ -37,38 +31,38 @@ type RuleTextBaseProps = { /** The character limit for the input */ characterLimit?: number; - /** The rule identifier */ + /** The form ID to read from Onyx */ + formID: TFormID; + + /** Callback when the form is saved */ + onSave: (values: FormOnyxValues) => void; + + /** Callback to go back */ + onBack: () => void; + + /** Optional hash for rule not found validation */ hash?: string; }; -function RuleTextBase({fieldID, hintKey, isRequired, titleKey, labelKey, testID, hash, characterLimit = CONST.MERCHANT_NAME_MAX_BYTES}: RuleTextBaseProps) { +function RuleTextBase({fieldID, hintKey, isRequired, titleKey, labelKey, testID, characterLimit, formID, onSave, onBack, hash}: RuleTextBaseProps) { const {translate} = useLocalize(); const styles = useThemeStyles(); - const goBack = () => { - Navigation.goBack(hash ? ROUTES.SETTINGS_RULES_EDIT.getRoute(hash) : ROUTES.SETTINGS_RULES_ADD.getRoute()); - }; - - const onSave = (values: FormOnyxValues) => { - updateDraftRule(values); - goBack(); - }; - return ( = { + fieldID: string; hint?: string; isRequired?: boolean; title: string; label: string; characterLimit?: number; - onSubmit: (values: FormOnyxValues) => void; + formID: TFormID; + onSubmit: (values: FormOnyxValues) => void; }; -function TextBase({fieldID, hint, isRequired, title, label, onSubmit, characterLimit = CONST.MERCHANT_NAME_MAX_BYTES}: TextBaseProps) { +function TextBase({fieldID, hint, isRequired, title, label, onSubmit, formID, characterLimit = CONST.MERCHANT_NAME_MAX_BYTES}: TextBaseProps) { const {translate} = useLocalize(); - const [form] = useOnyx(ONYXKEYS.FORMS.EXPENSE_RULE_FORM, {canBeMissing: true}); + const [form] = useOnyx(formID, {canBeMissing: true}); const styles = useThemeStyles(); - const currentValue = form?.[fieldID] ?? ''; + const currentValue = (form as Record)?.[fieldID] ?? ''; const {inputCallbackRef} = useAutoFocusInput(); - const validate = (values: FormOnyxValues) => { - const errors: FormInputErrors = {}; - const fieldValue = values[fieldID] ?? ''; + const validate = (values: FormOnyxValues) => { + const errors: FormInputErrors = {}; + const fieldValue = values[fieldID as keyof FormOnyxValues] ?? ''; if (typeof fieldValue !== 'string') { return errors; @@ -42,12 +42,12 @@ function TextBase({fieldID, hint, isRequired, title, label, onSubmit, characterL const trimmedValue = fieldValue.trim(); if (isRequired && !isRequiredFulfilled(fieldValue)) { - errors[fieldID] = translate('common.error.fieldRequired'); + (errors as Record)[fieldID] = translate('common.error.fieldRequired'); } else { const {isValid, byteLength} = isValidInputLength(trimmedValue, characterLimit); if (!isValid) { - errors[fieldID] = translate('common.error.characterLimitExceedCounter', byteLength, characterLimit); + (errors as Record)[fieldID] = translate('common.error.characterLimitExceedCounter', byteLength, characterLimit); } } @@ -57,7 +57,7 @@ function TextBase({fieldID, hint, isRequired, title, label, onSubmit, characterL return ( filter.key !== CONST.SEARCH.SYNTAX_FILTER_KEYS.TAG); + newFlatFilters.push({key: CONST.SEARCH.SYNTAX_FILTER_KEYS.TAG, filters: [{operator: CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO, value: tagValue}]}); + const newQueryJSON: SearchQueryJSON = {...queryJSON, groupBy: undefined, flatFilters: newFlatFilters}; + const newQuery = buildSearchQueryString(newQueryJSON); + const newQueryJSONWithHash = buildSearchQueryJSON(newQuery); + if (!newQueryJSONWithHash) { + return; + } + handleSearch({queryJSON: newQueryJSONWithHash, searchKey, offset: 0, shouldCalculateTotals: false, isLoading: false}); + return; + } + + if (isTransactionMonthGroupListItemType(item)) { + const newFlatFilters = queryJSON.flatFilters.filter((filter) => filter.key !== CONST.SEARCH.SYNTAX_FILTER_KEYS.DATE); + const {start: monthStart, end: monthEnd} = DateUtils.getMonthDateRange(item.year, item.month); + newFlatFilters.push({ + key: CONST.SEARCH.SYNTAX_FILTER_KEYS.DATE, + filters: [ + {operator: CONST.SEARCH.SYNTAX_OPERATORS.GREATER_THAN_OR_EQUAL_TO, value: monthStart}, + {operator: CONST.SEARCH.SYNTAX_OPERATORS.LOWER_THAN_OR_EQUAL_TO, value: monthEnd}, + ], + }); + const newQueryJSON: SearchQueryJSON = {...queryJSON, groupBy: undefined, flatFilters: newFlatFilters}; + const newQuery = buildSearchQueryString(newQueryJSON); + const newQueryJSONWithHash = buildSearchQueryJSON(newQuery); + if (!newQueryJSONWithHash) { + return; + } + handleSearch({queryJSON: newQueryJSONWithHash, searchKey, offset: 0, shouldCalculateTotals: false, isLoading: false}); + return; + } + let reportID = item.reportID; if (isTransactionItem && item?.reportAction?.childReportID) { const isFromSelfDM = item.reportID === CONST.REPORT.UNREPORTED_REPORT_ID; diff --git a/src/components/Search/types.ts b/src/components/Search/types.ts index b1fc37b09a27..cd2ebebd90f1 100644 --- a/src/components/Search/types.ts +++ b/src/components/Search/types.ts @@ -124,7 +124,9 @@ type SearchCustomColumnIds = | ValueOf | ValueOf | ValueOf - | ValueOf; + | ValueOf + | ValueOf + | ValueOf; type SearchContextData = { currentSearchHash: number; diff --git a/src/components/SelectionListWithSections/ChatListItem.tsx b/src/components/SelectionListWithSections/ChatListItem.tsx index 445ed2b12dbd..c0684bf08164 100644 --- a/src/components/SelectionListWithSections/ChatListItem.tsx +++ b/src/components/SelectionListWithSections/ChatListItem.tsx @@ -3,7 +3,7 @@ import useAnimatedHighlightStyle from '@hooks/useAnimatedHighlightStyle'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import FS from '@libs/Fullstory'; -import ReportActionItem from '@pages/home/report/ReportActionItem'; +import ReportActionItem from '@pages/inbox/report/ReportActionItem'; import variables from '@styles/variables'; import ONYXKEYS from '@src/ONYXKEYS'; import BaseListItem from './BaseListItem'; diff --git a/src/components/SelectionListWithSections/Search/MonthListItemHeader.tsx b/src/components/SelectionListWithSections/Search/MonthListItemHeader.tsx new file mode 100644 index 000000000000..8b0a2aa9f117 --- /dev/null +++ b/src/components/SelectionListWithSections/Search/MonthListItemHeader.tsx @@ -0,0 +1,142 @@ +import React from 'react'; +import {View} from 'react-native'; +import Checkbox from '@components/Checkbox'; +import type {SearchColumnType} from '@components/Search/types'; +import type {ListItem, TransactionMonthGroupListItemType} from '@components/SelectionListWithSections/types'; +import TextWithTooltip from '@components/TextWithTooltip'; +import useLocalize from '@hooks/useLocalize'; +import useResponsiveLayout from '@hooks/useResponsiveLayout'; +import useStyleUtils from '@hooks/useStyleUtils'; +import useThemeStyles from '@hooks/useThemeStyles'; +import CONST from '@src/CONST'; +import ExpandCollapseArrowButton from './ExpandCollapseArrowButton'; +import TextCell from './TextCell'; +import TotalCell from './TotalCell'; + +type MonthListItemHeaderProps = { + /** The month group currently being looked at */ + month: TransactionMonthGroupListItemType; + + /** Callback to fire when a checkbox is pressed */ + onCheckboxPress?: (item: TItem) => void; + + /** Whether this section items disabled for selection */ + isDisabled?: boolean | null; + + /** Whether selecting multiple transactions at once is allowed */ + canSelectMultiple: boolean | undefined; + + /** Whether all transactions are selected */ + isSelectAllChecked?: boolean; + + /** Whether only some transactions are selected */ + isIndeterminate?: boolean; + + /** Callback for when the down arrow is clicked */ + onDownArrowClick?: () => void; + + /** Whether the down arrow is expanded */ + isExpanded?: boolean; + + /** The visible columns for the header */ + columns?: SearchColumnType[]; +}; + +function MonthListItemHeader({ + month: monthItem, + onCheckboxPress, + isDisabled, + canSelectMultiple, + isSelectAllChecked, + isIndeterminate, + isExpanded, + onDownArrowClick, + columns, +}: MonthListItemHeaderProps) { + const styles = useThemeStyles(); + const StyleUtils = useStyleUtils(); + const {isLargeScreenWidth} = useResponsiveLayout(); + const {translate} = useLocalize(); + const monthName = monthItem.formattedMonth; + + const columnComponents = { + [CONST.SEARCH.TABLE_COLUMNS.GROUP_MONTH]: ( + + + + + + ), + [CONST.SEARCH.TABLE_COLUMNS.GROUP_EXPENSES]: ( + + + + ), + [CONST.SEARCH.TABLE_COLUMNS.GROUP_TOTAL]: ( + + + + ), + }; + + return ( + + + + {!!canSelectMultiple && ( + onCheckboxPress?.(monthItem as unknown as TItem)} + isChecked={isSelectAllChecked} + isIndeterminate={isIndeterminate} + disabled={!!isDisabled || monthItem.isDisabledCheckbox} + accessibilityLabel={translate('common.select')} + style={isLargeScreenWidth && styles.mr1} + /> + )} + {!isLargeScreenWidth && ( + + + + + + )} + {isLargeScreenWidth && columns?.map((column) => columnComponents[column as keyof typeof columnComponents])} + + {!isLargeScreenWidth && ( + + + {!!onDownArrowClick && ( + + )} + + )} + + + ); +} + +export default MonthListItemHeader; diff --git a/src/components/SelectionListWithSections/Search/TagListItemHeader.tsx b/src/components/SelectionListWithSections/Search/TagListItemHeader.tsx new file mode 100644 index 000000000000..cdd6bf743c2a --- /dev/null +++ b/src/components/SelectionListWithSections/Search/TagListItemHeader.tsx @@ -0,0 +1,144 @@ +import React from 'react'; +import {View} from 'react-native'; +import Checkbox from '@components/Checkbox'; +import type {SearchColumnType} from '@components/Search/types'; +import type {ListItem, TransactionTagGroupListItemType} from '@components/SelectionListWithSections/types'; +import TextWithTooltip from '@components/TextWithTooltip'; +import useLocalize from '@hooks/useLocalize'; +import useResponsiveLayout from '@hooks/useResponsiveLayout'; +import useStyleUtils from '@hooks/useStyleUtils'; +import useThemeStyles from '@hooks/useThemeStyles'; +import CONST from '@src/CONST'; +import ExpandCollapseArrowButton from './ExpandCollapseArrowButton'; +import TextCell from './TextCell'; +import TotalCell from './TotalCell'; + +type TagListItemHeaderProps = { + /** The tag currently being looked at */ + tag: TransactionTagGroupListItemType; + + /** Callback to fire when a checkbox is pressed */ + onCheckboxPress?: (item: TItem) => void; + + /** Whether this section items disabled for selection */ + isDisabled?: boolean | null; + + /** Whether selecting multiple transactions at once is allowed */ + canSelectMultiple: boolean | undefined; + + /** Whether all transactions are selected */ + isSelectAllChecked?: boolean; + + /** Whether only some transactions are selected */ + isIndeterminate?: boolean; + + /** Callback for when the down arrow is clicked */ + onDownArrowClick?: () => void; + + /** Whether the down arrow is expanded */ + isExpanded?: boolean; + + /** The visible columns for the header */ + columns?: SearchColumnType[]; +}; + +function TagListItemHeader({ + tag: tagItem, + onCheckboxPress, + isDisabled, + canSelectMultiple, + isSelectAllChecked, + isIndeterminate, + isExpanded, + onDownArrowClick, + columns, +}: TagListItemHeaderProps) { + const styles = useThemeStyles(); + const StyleUtils = useStyleUtils(); + const {isLargeScreenWidth} = useResponsiveLayout(); + const {translate} = useLocalize(); + + // formattedTag is already translated to "No tag" for empty values in SearchUIUtils + const tagName = tagItem.formattedTag ?? tagItem.tag ?? ''; + + const columnComponents = { + [CONST.SEARCH.TABLE_COLUMNS.GROUP_TAG]: ( + + + + + + ), + [CONST.SEARCH.TABLE_COLUMNS.GROUP_EXPENSES]: ( + + + + ), + [CONST.SEARCH.TABLE_COLUMNS.GROUP_TOTAL]: ( + + + + ), + }; + + return ( + + + + {!!canSelectMultiple && ( + onCheckboxPress?.(tagItem as unknown as TItem)} + isChecked={isSelectAllChecked} + isIndeterminate={isIndeterminate} + disabled={!!isDisabled || tagItem.isDisabledCheckbox} + accessibilityLabel={translate('common.select')} + style={isLargeScreenWidth && styles.mr1} + /> + )} + {!isLargeScreenWidth && ( + + + + + + )} + {isLargeScreenWidth && columns?.map((column) => columnComponents[column as keyof typeof columnComponents])} + + {!isLargeScreenWidth && ( + + + {!!onDownArrowClick && ( + + )} + + )} + + + ); +} + +export default TagListItemHeader; diff --git a/src/components/SelectionListWithSections/Search/TransactionGroupListItem.tsx b/src/components/SelectionListWithSections/Search/TransactionGroupListItem.tsx index 6c01b1b80c42..ef132f27f54d 100644 --- a/src/components/SelectionListWithSections/Search/TransactionGroupListItem.tsx +++ b/src/components/SelectionListWithSections/Search/TransactionGroupListItem.tsx @@ -18,7 +18,9 @@ import type { TransactionGroupListItemType, TransactionListItemType, TransactionMemberGroupListItemType, + TransactionMonthGroupListItemType, TransactionReportGroupListItemType, + TransactionTagGroupListItemType, TransactionWithdrawalIDGroupListItemType, } from '@components/SelectionListWithSections/types'; import useAnimatedHighlightStyle from '@hooks/useAnimatedHighlightStyle'; @@ -42,7 +44,9 @@ import type {ReportAction, ReportActions} from '@src/types/onyx'; import CardListItemHeader from './CardListItemHeader'; import CategoryListItemHeader from './CategoryListItemHeader'; import MemberListItemHeader from './MemberListItemHeader'; +import MonthListItemHeader from './MonthListItemHeader'; import ReportListItemHeader from './ReportListItemHeader'; +import TagListItemHeader from './TagListItemHeader'; import TransactionGroupListExpandedItem from './TransactionGroupListExpanded'; import WithdrawalIDListItemHeader from './WithdrawalIDListItemHeader'; @@ -296,6 +300,32 @@ function TransactionGroupListItem({ isExpanded={isExpanded} /> ), + [CONST.SEARCH.GROUP_BY.TAG]: ( + + ), + [CONST.SEARCH.GROUP_BY.MONTH]: ( + + ), }; if (searchType === CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT) { diff --git a/src/components/SelectionListWithSections/SearchTableHeader.tsx b/src/components/SelectionListWithSections/SearchTableHeader.tsx index 758af3afc6b5..242e7c7f3c04 100644 --- a/src/components/SelectionListWithSections/SearchTableHeader.tsx +++ b/src/components/SelectionListWithSections/SearchTableHeader.tsx @@ -355,6 +355,42 @@ const getTransactionGroupHeaders = (groupBy: SearchGroupBy, icons: SearchHeaderI isColumnSortable: true, }, ]; + case CONST.SEARCH.GROUP_BY.TAG: + return [ + { + columnName: CONST.SEARCH.TABLE_COLUMNS.GROUP_TAG, + translationKey: 'common.tag', + isColumnSortable: true, + }, + { + columnName: CONST.SEARCH.TABLE_COLUMNS.GROUP_EXPENSES, + translationKey: 'common.expenses', + isColumnSortable: true, + }, + { + columnName: CONST.SEARCH.TABLE_COLUMNS.GROUP_TOTAL, + translationKey: 'common.total', + isColumnSortable: true, + }, + ]; + case CONST.SEARCH.GROUP_BY.MONTH: + return [ + { + columnName: CONST.SEARCH.TABLE_COLUMNS.GROUP_MONTH, + translationKey: 'common.month', + isColumnSortable: true, + }, + { + columnName: CONST.SEARCH.TABLE_COLUMNS.GROUP_EXPENSES, + translationKey: 'common.expenses', + isColumnSortable: true, + }, + { + columnName: CONST.SEARCH.TABLE_COLUMNS.GROUP_TOTAL, + translationKey: 'common.total', + isColumnSortable: true, + }, + ]; default: return []; } diff --git a/src/components/SelectionListWithSections/types.ts b/src/components/SelectionListWithSections/types.ts index ab63f246a7fd..3c6ba08d8231 100644 --- a/src/components/SelectionListWithSections/types.ts +++ b/src/components/SelectionListWithSections/types.ts @@ -28,7 +28,17 @@ import type CONST from '@src/CONST'; import type {PersonalDetails, PersonalDetailsList, Policy, Report, ReportAction, SearchResults, TransactionViolation, TransactionViolations} from '@src/types/onyx'; import type {Attendee} from '@src/types/onyx/IOU'; import type {Errors, Icon, PendingAction} from '@src/types/onyx/OnyxCommon'; -import type {SearchCardGroup, SearchCategoryGroup, SearchDataTypes, SearchMemberGroup, SearchTask, SearchTransactionAction, SearchWithdrawalIDGroup} from '@src/types/onyx/SearchResults'; +import type { + SearchCardGroup, + SearchCategoryGroup, + SearchDataTypes, + SearchMemberGroup, + SearchMonthGroup, + SearchTagGroup, + SearchTask, + SearchTransactionAction, + SearchWithdrawalIDGroup, +} from '@src/types/onyx/SearchResults'; import type {ReceiptErrors} from '@src/types/onyx/Transaction'; import type Transaction from '@src/types/onyx/Transaction'; import type ChildrenProps from '@src/types/utils/ChildrenProps'; @@ -475,6 +485,14 @@ type TransactionMemberGroupListItemType = TransactionGroupListItemType & {groupe formattedFrom?: string; }; +type TransactionMonthGroupListItemType = TransactionGroupListItemType & {groupedBy: typeof CONST.SEARCH.GROUP_BY.MONTH} & SearchMonthGroup & { + /** Final and formatted "month" value used for displaying */ + formattedMonth: string; + + /** Key used for sorting */ + sortKey: number; + }; + type TransactionCardGroupListItemType = TransactionGroupListItemType & {groupedBy: typeof CONST.SEARCH.GROUP_BY.CARD} & PersonalDetails & SearchCardGroup & { /** Final and formatted "cardName" value used for displaying and sorting */ @@ -494,6 +512,11 @@ type TransactionCategoryGroupListItemType = TransactionGroupListItemType & {grou formattedCategory?: string; }; +type TransactionTagGroupListItemType = TransactionGroupListItemType & {groupedBy: typeof CONST.SEARCH.GROUP_BY.TAG} & SearchTagGroup & { + /** Final and formatted "tag" value used for displaying and sorting */ + formattedTag?: string; + }; + type ListItemProps = CommonListItemProps & { /** The section list item */ item: TItem; @@ -1133,9 +1156,11 @@ export type { TransactionGroupListItemType, TransactionReportGroupListItemType, TransactionMemberGroupListItemType, + TransactionMonthGroupListItemType, TransactionCardGroupListItemType, TransactionWithdrawalIDGroupListItemType, TransactionCategoryGroupListItemType, + TransactionTagGroupListItemType, Section, SectionListDataType, SectionWithIndexOffset, diff --git a/src/components/ShowContextMenuContext.ts b/src/components/ShowContextMenuContext.ts index 2eaa1fbf46c5..90c873002c09 100644 --- a/src/components/ShowContextMenuContext.ts +++ b/src/components/ShowContextMenuContext.ts @@ -4,8 +4,8 @@ import type {GestureResponderEvent} from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; import {canUseTouchScreen} from '@libs/DeviceCapabilities'; import {getOriginalReportID} from '@libs/ReportUtils'; -import {showContextMenu} from '@pages/home/report/ContextMenu/ReportActionContextMenu'; -import type {ContextMenuAnchor} from '@pages/home/report/ContextMenu/ReportActionContextMenu'; +import {showContextMenu} from '@pages/inbox/report/ContextMenu/ReportActionContextMenu'; +import type {ContextMenuAnchor} from '@pages/inbox/report/ContextMenu/ReportActionContextMenu'; import CONST from '@src/CONST'; import type {Report, ReportAction} from '@src/types/onyx'; diff --git a/src/components/SidePanel/SidePanelReport/index.tsx b/src/components/SidePanel/SidePanelReport/index.tsx index ba6e527107ac..a8c61b697a01 100644 --- a/src/components/SidePanel/SidePanelReport/index.tsx +++ b/src/components/SidePanel/SidePanelReport/index.tsx @@ -2,7 +2,7 @@ import {NavigationRouteContext} from '@react-navigation/native'; import React from 'react'; import type {ExtraContentProps, PlatformStackNavigationProp} from '@libs/Navigation/PlatformStackNavigation/types'; import type {ReportsSplitNavigatorParamList} from '@libs/Navigation/types'; -import ReportScreen from '@pages/home/ReportScreen'; +import ReportScreen from '@pages/inbox/ReportScreen'; import SCREENS from '@src/SCREENS'; type SidePanelReportProps = Pick & { diff --git a/src/components/TestDrive/Modal/EmployeeTestDriveModal.tsx b/src/components/TestDrive/Modal/EmployeeTestDriveModal.tsx index 2322dd99b9b9..56bcd7f51148 100644 --- a/src/components/TestDrive/Modal/EmployeeTestDriveModal.tsx +++ b/src/components/TestDrive/Modal/EmployeeTestDriveModal.tsx @@ -49,6 +49,7 @@ function EmployeeTestDriveModal() { const currentUserPersonalDetails = useCurrentUserPersonalDetails(); const personalPolicy = usePersonalPolicy(); const [allPolicies] = useOnyx(ONYXKEYS.COLLECTION.POLICY, {canBeMissing: true}); + const [draftTransactions] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION_DRAFT, {canBeMissing: true}); const hasOnlyPersonalPolicies = useMemo(() => hasOnlyPersonalPoliciesUtil(allPolicies), [allPolicies]); const onBossEmailChange = useCallback((value: string) => { @@ -85,6 +86,7 @@ function EmployeeTestDriveModal() { currentDate, currentUserPersonalDetails, hasOnlyPersonalPolicies, + draftTransactions, }); setMoneyRequestReceipt(transactionID, source, filename, true, CONST.TEST_RECEIPT.FILE_TYPE, false, true); diff --git a/src/components/TestToolMenu.tsx b/src/components/TestToolMenu.tsx index eadc8f7d0989..35b3d709a011 100644 --- a/src/components/TestToolMenu.tsx +++ b/src/components/TestToolMenu.tsx @@ -1,5 +1,6 @@ import React from 'react'; import {View} from 'react-native'; +import type {OnyxEntry} from 'react-native-onyx'; import useIsAuthenticated from '@hooks/useIsAuthenticated'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; @@ -7,6 +8,7 @@ import {useSidebarOrderedReports} from '@hooks/useSidebarOrderedReports'; import useSingleExecution from '@hooks/useSingleExecution'; import useThemeStyles from '@hooks/useThemeStyles'; import useWaitForNavigation from '@hooks/useWaitForNavigation'; +import {revokeMultifactorAuthenticationCredentials} from '@libs/actions/MultifactorAuthentication'; import {isUsingStagingApi} from '@libs/ApiUtils'; import Navigation from '@libs/Navigation/Navigation'; import {setShouldFailAllRequests, setShouldForceOffline, setShouldSimulatePoorConnection} from '@userActions/Network'; @@ -15,6 +17,7 @@ import {setIsDebugModeEnabled, setShouldUseStagingServer} from '@userActions/Use import CONFIG from '@src/CONFIG'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; +import type {Account} from '@src/types/onyx'; import Button from './Button'; import SoftKillTestToolRow from './SoftKillTestToolRow'; import Switch from './Switch'; @@ -22,8 +25,9 @@ import TestCrash from './TestCrash'; import TestToolRow from './TestToolRow'; import Text from './Text'; -// Temporary hardcoded value until MultifactorAuthenticationContext is implemented -const TEMP_BIOMETRICS_REGISTERED_STATUS = false; +function getHasBiometricsRegistered(data: OnyxEntry) { + return data?.multifactorAuthenticationPublicKeyIDs && data.multifactorAuthenticationPublicKeyIDs.length > 0; +} function TestToolMenu() { const [network] = useOnyx(ONYXKEYS.NETWORK, {canBeMissing: true}); @@ -33,6 +37,7 @@ function TestToolMenu() { const styles = useThemeStyles(); const {translate} = useLocalize(); const {clearLHNCache} = useSidebarOrderedReports(); + const [hasBiometricsRegistered = false] = useOnyx(ONYXKEYS.ACCOUNT, {canBeMissing: true, selector: getHasBiometricsRegistered}); const {singleExecution} = useSingleExecution(); const waitForNavigate = useWaitForNavigation(); @@ -51,7 +56,7 @@ function TestToolMenu() { const isAuthenticated = useIsAuthenticated(); // Temporary hardcoded false, expected behavior: status fetched from the MultifactorAuthenticationContext - const biometricsTitle = translate('multifactorAuthentication.biometricsTest.troubleshootBiometricsStatus', {registered: TEMP_BIOMETRICS_REGISTERED_STATUS}); + const biometricsTitle = translate('multifactorAuthentication.biometricsTest.troubleshootBiometricsStatus', {registered: hasBiometricsRegistered}); return ( <> @@ -116,6 +121,15 @@ function TestToolMenu() { text={translate('multifactorAuthentication.biometricsTest.test')} onPress={() => navigateToBiometricsTestPage()} /> + {hasBiometricsRegistered && ( + diff --git a/src/components/TestToolsModalPage.tsx b/src/components/TestToolsModalPage.tsx index f1a563560865..661d0992ee19 100644 --- a/src/components/TestToolsModalPage.tsx +++ b/src/components/TestToolsModalPage.tsx @@ -38,7 +38,7 @@ function TestToolsModalPage() { // If no backTo param is provided (direct access to /test-tools), // use home route as a default backTo param for console navigation - const effectiveBackTo = backTo ?? ROUTES.HOME; + const effectiveBackTo = backTo ?? ROUTES.INBOX; const consoleRoute = getRouteBasedOnAuthStatus(isAuthenticated, effectiveBackTo); const maxHeight = windowHeight; diff --git a/src/components/TextWithCopy.tsx b/src/components/TextWithCopy.tsx index a969edf0dece..498fe1d450fc 100644 --- a/src/components/TextWithCopy.tsx +++ b/src/components/TextWithCopy.tsx @@ -1,7 +1,7 @@ import React, {useRef} from 'react'; import type {GestureResponderEvent, View} from 'react-native'; import useThemeStyles from '@hooks/useThemeStyles'; -import {showContextMenu} from '@pages/home/report/ContextMenu/ReportActionContextMenu'; +import {showContextMenu} from '@pages/inbox/report/ContextMenu/ReportActionContextMenu'; import CONST from '@src/CONST'; import PressableWithSecondaryInteraction from './PressableWithSecondaryInteraction'; import Text from './Text'; diff --git a/src/hooks/useBasePopoverReactionList/types.ts b/src/hooks/useBasePopoverReactionList/types.ts index ad48b0c14c29..1b944f8cd233 100644 --- a/src/hooks/useBasePopoverReactionList/types.ts +++ b/src/hooks/useBasePopoverReactionList/types.ts @@ -2,7 +2,7 @@ import type {ForwardedRef} from 'react'; import type {OnyxEntry} from 'react-native-onyx'; import type {LocaleContextProps} from '@components/LocaleContextProvider'; import type {WithCurrentUserPersonalDetailsProps} from '@components/withCurrentUserPersonalDetails'; -import type {ReactionListAnchor, ReactionListEvent, ReactionListRef} from '@pages/home/ReportScreenContext'; +import type {ReactionListAnchor, ReactionListEvent, ReactionListRef} from '@pages/inbox/ReportScreenContext'; import type {ReportActionReactions} from '@src/types/onyx'; type BasePopoverReactionListProps = { diff --git a/src/hooks/useReportScrollManager/index.native.ts b/src/hooks/useReportScrollManager/index.native.ts index fa0042a3bf26..39e5a1b66ee7 100644 --- a/src/hooks/useReportScrollManager/index.native.ts +++ b/src/hooks/useReportScrollManager/index.native.ts @@ -1,7 +1,7 @@ import {useCallback, useContext} from 'react'; // eslint-disable-next-line no-restricted-imports import type {ScrollView} from 'react-native'; -import {ActionListContext} from '@pages/home/ReportScreenContext'; +import {ActionListContext} from '@pages/inbox/ReportScreenContext'; import type ReportScrollManagerData from './types'; function useReportScrollManager(): ReportScrollManagerData { diff --git a/src/hooks/useReportScrollManager/index.ts b/src/hooks/useReportScrollManager/index.ts index e5e95b77bbe8..30c383e62c2f 100644 --- a/src/hooks/useReportScrollManager/index.ts +++ b/src/hooks/useReportScrollManager/index.ts @@ -1,5 +1,5 @@ import {useCallback, useContext} from 'react'; -import {ActionListContext} from '@pages/home/ReportScreenContext'; +import {ActionListContext} from '@pages/inbox/ReportScreenContext'; import type ReportScrollManagerData from './types'; function useReportScrollManager(): ReportScrollManagerData { diff --git a/src/hooks/useReportScrollManager/types.ts b/src/hooks/useReportScrollManager/types.ts index 6706f00e1744..44ccda75e55f 100644 --- a/src/hooks/useReportScrollManager/types.ts +++ b/src/hooks/useReportScrollManager/types.ts @@ -1,4 +1,4 @@ -import type {FlatListRefType} from '@pages/home/ReportScreenContext'; +import type {FlatListRefType} from '@pages/inbox/ReportScreenContext'; type ReportScrollManagerData = { ref: FlatListRefType; diff --git a/src/languages/de.ts b/src/languages/de.ts index ed73747e2649..4348d6af9605 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -17,6 +17,8 @@ import dedent from '@libs/StringUtils/dedent'; import CONST from '@src/CONST'; import type {Country} from '@src/CONST'; import type OriginalMessage from '@src/types/onyx/OriginalMessage'; +import {PolicyRulesModifiedFields} from '@src/types/onyx/OriginalMessage'; +import ObjectUtils from '@src/types/utils/ObjectUtils'; import type en from './en'; import type { ChangeFieldParams, @@ -637,6 +639,8 @@ const translations: TranslationDeepObject = { insights: 'Einblicke', duplicateExpense: 'Doppelte Ausgabe', newFeature: 'Neue Funktion', + month: 'Monat', + home: 'Startseite', }, supportalNoAccess: { title: 'Nicht so schnell', @@ -759,6 +763,17 @@ const translations: TranslationDeepObject = { enableQuickVerification: { biometrics: 'Schnelle, sichere Verifizierung mit deinem Gesicht oder Fingerabdruck aktivieren. Keine Passwörter oder Codes erforderlich.', }, + revoke: { + revoke: 'Widerrufen', + title: 'Gesicht/Fingerabdruck & Passkeys', + explanation: + 'Die Gesichts-/Fingerabdruck- oder Passkey-Verifizierung ist auf einem oder mehreren Geräten aktiviert. Durch das Widerrufen des Zugriffs wird für die nächste Verifizierung auf jedem Gerät ein magischer Code erforderlich', + confirmationPrompt: 'Bist du sicher? Du benötigst einen magischen Code für die nächste Verifizierung auf jedem Gerät', + cta: 'Zugriff widerrufen', + noDevices: 'Du hast keine Geräte für Gesichts-/Fingerabdruck- oder Passkey-Verifizierung registriert. Wenn du welche registrierst, kannst du den Zugriff hier widerrufen.', + dismiss: 'Verstanden', + error: 'Anfrage fehlgeschlagen. Versuchen Sie es später noch einmal.', + }, }, validateCodeModal: { successfulSignInTitle: dedent(` @@ -781,7 +796,7 @@ const translations: TranslationDeepObject = { expiredCodeDescription: 'Gehe zurück zum ursprünglichen Gerät und fordere einen neuen Code an', successfulNewCodeRequest: 'Code angefordert. Bitte überprüfe dein Gerät.', tfaRequiredTitle: dedent(` - Zwei-Faktor-Authentifizierung + Zwei-Faktor-Authentifizierung erforderlich `), tfaRequiredDescription: dedent(` @@ -914,6 +929,7 @@ const translations: TranslationDeepObject = { beginningOfChatHistorySelfDM: 'Dies ist dein persönlicher Bereich. Verwende ihn für Notizen, Aufgaben, Entwürfe und Erinnerungen.', beginningOfChatHistorySystemDM: 'Willkommen! Lass uns alles für dich einrichten.', chatWithAccountManager: 'Chatten Sie hier mit Ihrem Account Manager', + askMeAnything: 'Frag mich alles!', sayHello: 'Sag Hallo!', yourSpace: 'Ihr Bereich', welcomeToRoom: ({roomName}: WelcomeToRoomParams) => `Willkommen bei ${roomName}!`, @@ -1487,6 +1503,32 @@ const translations: TranslationDeepObject = { }, correctDistanceRateError: 'Beheben Sie den Fehler beim Entfernungssatz und versuchen Sie es erneut.', AskToExplain: `. Erklären ✨`, + policyRulesModifiedFields: (policyRulesModifiedFields: PolicyRulesModifiedFields, policyRulesRoute: string, formatList: (list: string[]) => string) => { + const entries = ObjectUtils.typedEntries(policyRulesModifiedFields); + const fragments = entries.map(([key, value], i) => { + const isFirst = i === 0; + if (key === 'reimbursable') { + return value ? 'hat die Ausgabe als „erstattungsfähig“ markiert' : 'hat die Ausgabe als „nicht erstattungsfähig“ markiert'; + } + if (key === 'billable') { + return value ? 'hat die Ausgabe als „verrechenbar“ markiert' : 'hat die Ausgabe als „nicht abrechenbar“ markiert'; + } + if (key === 'tax') { + const taxEntry = value as PolicyRulesModifiedFields['tax']; + const taxRateName = taxEntry?.field_id_TAX.name ?? ''; + if (isFirst) { + return `Steuersatz auf „${taxRateName}“ festlegen`; + } + return `Steuersatz zu „${taxRateName}“`; + } + const updatedValue = value as string | boolean; + if (isFirst) { + return `Setzen Sie ${translations.common[key].toLowerCase()} auf „${updatedValue}“`; + } + return `${translations.common[key].toLowerCase()} zu "${updatedValue}"`; + }); + return `${formatList(fragments)} über Workspace-Regeln`; + }, }, transactionMerge: { listPage: { @@ -6342,6 +6384,17 @@ Fordere Spesendetails wie Belege und Beschreibungen an, lege Limits und Standard ruleSummarySubtitleUpdateField: (fieldName: string, fieldValue: string) => `Aktualisiere ${fieldName} zu „${fieldValue}“`, ruleSummarySubtitleReimbursable: (reimbursable: boolean) => `Als "${reimbursable ? 'erstattungsfähig' : 'nicht erstattungsfähig'}" markieren`, ruleSummarySubtitleBillable: (billable: boolean) => `Als „${billable ? 'Abrechenbar' : 'nicht abrechenbar'}“ markieren`, + addRuleTitle: 'Regel hinzufügen', + expensesWith: 'Für Ausgaben mit:', + applyUpdates: 'Diese Updates anwenden:', + merchantHint: 'Einem Händlernamen mit groß-/kleinschreibungsunabhängiger „Enthält“-Übereinstimmung zuordnen', + saveRule: 'Regel speichern', + confirmError: 'Geben Sie den Händler ein und nehmen Sie mindestens eine Änderung vor', + confirmErrorMerchant: 'Bitte geben Sie den Händler ein', + confirmErrorUpdate: 'Bitte wenden Sie mindestens eine Aktualisierung an', + editRuleTitle: 'Regel bearbeiten', + deleteRule: 'Regel löschen', + deleteRuleConfirmation: 'Sind Sie sicher, dass Sie diese Regel löschen möchten?', }, }, planTypePage: { @@ -6899,7 +6952,8 @@ Fordere Spesendetails wie Belege und Beschreibungen an, lege Limits und Standard presets: { [CONST.SEARCH.DATE_PRESETS.NEVER]: 'Nie', [CONST.SEARCH.DATE_PRESETS.LAST_MONTH]: 'Letzter Monat', - [CONST.SEARCH.DATE_PRESETS.THIS_MONTH]: 'Diesen Monat', + [CONST.SEARCH.DATE_PRESETS.THIS_MONTH]: 'Dieser Monat', + [CONST.SEARCH.DATE_PRESETS.YEAR_TO_DATE]: 'Laufendes Jahr', [CONST.SEARCH.DATE_PRESETS.LAST_STATEMENT]: 'Letzter Kontoauszug', }, }, @@ -6941,6 +6995,8 @@ Fordere Spesendetails wie Belege und Beschreibungen an, lege Limits und Standard [CONST.SEARCH.GROUP_BY.CARD]: 'Karte', [CONST.SEARCH.GROUP_BY.WITHDRAWAL_ID]: 'Auszahlungs-ID', [CONST.SEARCH.GROUP_BY.CATEGORY]: 'Kategorie', + [CONST.SEARCH.GROUP_BY.TAG]: 'Stichwort', + [CONST.SEARCH.GROUP_BY.MONTH]: 'Monat', }, feed: 'Feed', withdrawalType: { diff --git a/src/languages/en.ts b/src/languages/en.ts index f724200f67cd..74ab2fd16c0f 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -6,6 +6,8 @@ import dedent from '@libs/StringUtils/dedent'; import CONST from '@src/CONST'; import type {Country} from '@src/CONST'; import type OriginalMessage from '@src/types/onyx/OriginalMessage'; +import type {PolicyRulesModifiedFields} from '@src/types/onyx/OriginalMessage'; +import ObjectUtils from '@src/types/utils/ObjectUtils'; import type { ChangeFieldParams, ConnectionNameParams, @@ -273,6 +275,7 @@ const translations = { digits: 'digits', twoFactorCode: 'Two-factor code', workspaces: 'Workspaces', + home: 'Home', inbox: 'Inbox', // @context Used in confirmation or result messages indicating that an action completed successfully, not the abstract noun “success.” success: 'Success', @@ -629,6 +632,7 @@ const translations = { exchangeRate: 'Exchange rate', reimbursableTotal: 'Reimbursable total', nonReimbursableTotal: 'Non-reimbursable total', + month: 'Month', }, supportalNoAccess: { title: 'Not so fast', @@ -747,6 +751,16 @@ const translations = { enableQuickVerification: { biometrics: 'Enable quick, secure verification using your face or fingerprint. No passwords or codes required.', }, + revoke: { + revoke: 'Revoke', + title: 'Face/fingerprint & passkeys', + explanation: 'Face/fingerprint or passkey verification are enabled on one or more devices. Revoking access will require a magic code for the next verification on any device', + confirmationPrompt: "Are you sure? You'll need a magic code for the next verification on any device", + cta: 'Revoke access', + noDevices: "You don't have any devices registered for face/fingerprint or passkey verification. If you register any, you will be able to revoke that access here.", + dismiss: 'Got it', + error: 'Request failed. Try again later.', + }, }, validateCodeModal: { successfulSignInTitle: dedent(` @@ -900,6 +914,7 @@ const translations = { beginningOfChatHistorySelfDM: 'This is your personal space. Use it for notes, tasks, drafts, and reminders.', beginningOfChatHistorySystemDM: "Welcome! Let's get you set up.", chatWithAccountManager: 'Chat with your account manager here', + askMeAnything: 'Ask me anything!', sayHello: 'Say hello!', yourSpace: 'Your space', welcomeToRoom: ({roomName}: WelcomeToRoomParams) => `Welcome to ${roomName}!`, @@ -1471,6 +1486,39 @@ const translations = { }, correctDistanceRateError: 'Fix the distance rate error and try again.', AskToExplain: `. Explain ✨`, + policyRulesModifiedFields: (policyRulesModifiedFields: PolicyRulesModifiedFields, policyRulesRoute: string, formatList: (list: string[]) => string) => { + const entries = ObjectUtils.typedEntries(policyRulesModifiedFields); + + const fragments = entries.map(([key, value], i) => { + const isFirst = i === 0; + + if (key === 'reimbursable') { + return value ? 'marked the expense as "reimbursable"' : 'marked the expense as "non-reimbursable"'; + } + + if (key === 'billable') { + return value ? 'marked the expense as "billable"' : 'marked the expense as "non-billable"'; + } + + if (key === 'tax') { + const taxEntry = value as PolicyRulesModifiedFields['tax']; + const taxRateName = taxEntry?.field_id_TAX.name ?? ''; + if (isFirst) { + return `set the tax rate to "${taxRateName}"`; + } + return `tax rate to "${taxRateName}"`; + } + + const updatedValue = value as string | boolean; + if (isFirst) { + return `set the ${translations.common[key].toLowerCase()} to "${updatedValue}"`; + } + + return `${translations.common[key].toLowerCase()} to "${updatedValue}"`; + }); + + return `${formatList(fragments)} via workspace rules`; + }, }, transactionMerge: { listPage: { @@ -6179,6 +6227,17 @@ const translations = { title: 'Merchant', subtitle: 'Set the merchant rules so expenses arrive correctly coded and require less cleanup.', addRule: 'Add merchant rule', + addRuleTitle: 'Add rule', + editRuleTitle: 'Edit rule', + expensesWith: 'For expenses with:', + applyUpdates: 'Apply these updates:', + merchantHint: 'Match a merchant name with case-insensitive "contains" matching', + saveRule: 'Save rule', + confirmError: 'Enter merchant and apply at least one update', + confirmErrorMerchant: 'Please enter merchant', + confirmErrorUpdate: 'Please apply at least one update', + deleteRule: 'Delete rule', + deleteRuleConfirmation: 'Are you sure you want to delete this rule?', ruleSummaryTitle: (merchantName: string) => `If merchant contains "${merchantName}"`, ruleSummarySubtitleMerchant: (merchantName: string) => `Rename merchant to "${merchantName}"`, ruleSummarySubtitleUpdateField: (fieldName: string, fieldValue: string) => `Update ${fieldName} to "${fieldValue}"`, @@ -6786,6 +6845,7 @@ const translations = { [CONST.SEARCH.DATE_PRESETS.NEVER]: 'Never', [CONST.SEARCH.DATE_PRESETS.LAST_MONTH]: 'Last month', [CONST.SEARCH.DATE_PRESETS.THIS_MONTH]: 'This month', + [CONST.SEARCH.DATE_PRESETS.YEAR_TO_DATE]: 'Year to date', [CONST.SEARCH.DATE_PRESETS.LAST_STATEMENT]: 'Last statement', }, }, @@ -6827,6 +6887,8 @@ const translations = { [CONST.SEARCH.GROUP_BY.CARD]: 'Card', [CONST.SEARCH.GROUP_BY.WITHDRAWAL_ID]: 'Withdrawal ID', [CONST.SEARCH.GROUP_BY.CATEGORY]: 'Category', + [CONST.SEARCH.GROUP_BY.TAG]: 'Tag', + [CONST.SEARCH.GROUP_BY.MONTH]: 'Month', }, feed: 'Feed', withdrawalType: { diff --git a/src/languages/es.ts b/src/languages/es.ts index a2d73c055d3f..3df43c2b0497 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -1,6 +1,8 @@ import {CONST as COMMON_CONST} from 'expensify-common'; import dedent from '@libs/StringUtils/dedent'; import CONST from '@src/CONST'; +import type {PolicyRulesModifiedFields} from '@src/types/onyx/OriginalMessage'; +import ObjectUtils from '@src/types/utils/ObjectUtils'; import type en from './en'; import type {CreatedReportForUnapprovedTransactionsParams, PaidElsewhereParams, RoutedDueToDEWParams, SplitDateRangeParams, ViolationsRterParams} from './params'; import type {TranslationDeepObject} from './types'; @@ -55,6 +57,7 @@ const translations: TranslationDeepObject = { twoFactorCode: 'Autenticación de dos factores', workspaces: 'Espacios de trabajo', inbox: 'Recibidos', + home: 'Inicio', group: 'Grupo', profile: 'Perfil', referral: 'Remisión', @@ -388,6 +391,7 @@ const translations: TranslationDeepObject = { exchangeRate: 'Tipo de cambio', reimbursableTotal: 'Total reembolsable', nonReimbursableTotal: 'Total no reembolsable', + month: 'Monat', }, supportalNoAccess: { title: 'No tan rápido', @@ -506,6 +510,18 @@ const translations: TranslationDeepObject = { enableQuickVerification: { biometrics: 'Activa la verificación rápida y segura usando tu rostro o huella dactilar. No se requieren contraseñas ni códigos.', }, + revoke: { + revoke: 'Revocar', + title: 'Reconocimiento facial/huella digital y claves de acceso', + explanation: + 'La verificación mediante reconocimiento facial, huella digital o clave de acceso está habilitada en uno o más dispositivos. Revocar el acceso requerirá un código mágico para la próxima verificación en cualquier dispositivo.', + confirmationPrompt: '¿Estás seguro? Necesitarás un código mágico para la próxima verificación en cualquier dispositivo.', + cta: 'Revocar acceso', + noDevices: + 'No tienes ningún dispositivo registrado para la verificación mediante reconocimiento facial, huella digital o clave de acceso. Si registras alguno, podrás revocar ese acceso aquí.', + dismiss: 'Entendido', + error: 'La solicitud ha fallado. Inténtalo de nuevo más tarde.', + }, }, validateCodeModal: { successfulSignInTitle: 'Abracadabra,\n¡sesión iniciada!', @@ -646,6 +662,7 @@ const translations: TranslationDeepObject = { beginningOfChatHistorySelfDM: 'Este es tu espacio personal. Úsalo para notas, tareas, borradores y recordatorios.', beginningOfChatHistorySystemDM: '¡Bienvenido! Vamos a configurar tu cuenta.', chatWithAccountManager: 'Chatea con tu gestor de cuenta aquí', + askMeAnything: '¡Pregúntame lo que quieras!', sayHello: '¡Saluda!', yourSpace: 'Tu espacio', welcomeToRoom: ({roomName}) => `¡Bienvenido a ${roomName}!`, @@ -1216,6 +1233,39 @@ const translations: TranslationDeepObject = { }, correctDistanceRateError: 'Corrige el error de la tasa de distancia y vuelve a intentarlo.', AskToExplain: `. Explicar ✨`, + policyRulesModifiedFields: (policyRulesModifiedFields: PolicyRulesModifiedFields, policyRulesRoute: string, formatList: (list: string[]) => string) => { + const entries = ObjectUtils.typedEntries(policyRulesModifiedFields); + + const fragments = entries.map(([key, value], i) => { + const isFirst = i === 0; + + if (key === 'reimbursable') { + return value ? 'marcó el gasto como "reembolsable"' : 'marcó el gasto como "no reembolsable"'; + } + + if (key === 'billable') { + return value ? 'marcó el gasto como "facturable"' : 'marcó el gasto como "no facturable"'; + } + + if (key === 'tax') { + const taxEntry = value as PolicyRulesModifiedFields['tax']; + const taxRateName = taxEntry?.field_id_TAX.name ?? ''; + if (isFirst) { + return `estableció la tasa de impuesto a "${taxRateName}"`; + } + return `tasa de impuesto a "${taxRateName}"`; + } + + const updatedValue = value as string | boolean; + if (isFirst) { + return `estableció el ${translations.common[key].toLowerCase()} a "${updatedValue}"`; + } + + return `${translations.common[key].toLowerCase()} a "${updatedValue}"`; + }); + + return `${formatList(fragments)} a través de reglas del espacio de trabajo`; + }, }, transactionMerge: { listPage: { @@ -5954,6 +6004,17 @@ ${amount} para ${merchant} - ${date}`, title: 'Comerciante', subtitle: 'Configura las reglas de comerciante para que los gastos lleguen correctamente codificados y requieran menos limpieza.', addRule: 'Añadir regla de comerciante', + addRuleTitle: 'Añadir regla', + editRuleTitle: 'Editar regla', + expensesWith: 'Para gastos con:', + applyUpdates: 'Aplicar estas actualizaciones:', + merchantHint: 'Coincide con un nombre de comerciante con coincidencia "contiene" sin distinción de mayúsculas y minúsculas', + saveRule: 'Guardar regla', + confirmError: 'Ingresa comerciante y aplica al menos una actualización', + confirmErrorMerchant: 'Por favor ingresa comerciante', + confirmErrorUpdate: 'Por favor aplica al menos una actualización', + deleteRule: 'Eliminar regla', + deleteRuleConfirmation: '¿Estás seguro de que quieres eliminar esta regla?', ruleSummaryTitle: (merchantName: string) => `Si el comerciante contiene "${merchantName}"`, ruleSummarySubtitleMerchant: (merchantName: string) => `Renombrar comerciante a "${merchantName}"`, ruleSummarySubtitleUpdateField: (fieldName: string, fieldValue: string) => `Actualizar ${fieldName} a "${fieldValue}"`, @@ -6536,6 +6597,7 @@ ${amount} para ${merchant} - ${date}`, [CONST.SEARCH.DATE_PRESETS.NEVER]: 'Nunca', [CONST.SEARCH.DATE_PRESETS.LAST_MONTH]: 'El mes pasado', [CONST.SEARCH.DATE_PRESETS.THIS_MONTH]: 'Este mes', + [CONST.SEARCH.DATE_PRESETS.YEAR_TO_DATE]: 'Año hasta la fecha', [CONST.SEARCH.DATE_PRESETS.LAST_STATEMENT]: 'Último extracto', }, }, @@ -6575,6 +6637,8 @@ ${amount} para ${merchant} - ${date}`, [CONST.SEARCH.GROUP_BY.CARD]: 'Tarjeta', [CONST.SEARCH.GROUP_BY.WITHDRAWAL_ID]: 'ID de retiro', [CONST.SEARCH.GROUP_BY.CATEGORY]: 'Categoría', + [CONST.SEARCH.GROUP_BY.TAG]: 'Etiqueta', + [CONST.SEARCH.GROUP_BY.MONTH]: 'Mes', }, feed: 'Feed', withdrawalType: { diff --git a/src/languages/fr.ts b/src/languages/fr.ts index bc59f040587d..4b1b826482c4 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -17,6 +17,8 @@ import dedent from '@libs/StringUtils/dedent'; import CONST from '@src/CONST'; import type {Country} from '@src/CONST'; import type OriginalMessage from '@src/types/onyx/OriginalMessage'; +import {PolicyRulesModifiedFields} from '@src/types/onyx/OriginalMessage'; +import ObjectUtils from '@src/types/utils/ObjectUtils'; import type en from './en'; import type { ChangeFieldParams, @@ -639,6 +641,8 @@ const translations: TranslationDeepObject = { insights: 'Analyses', duplicateExpense: 'Note de frais en double', newFeature: 'Nouvelle fonctionnalité', + month: 'Mois', + home: 'Accueil', }, supportalNoAccess: { title: 'Pas si vite', @@ -762,6 +766,18 @@ const translations: TranslationDeepObject = { enableQuickVerification: { biometrics: 'Activez une vérification rapide et sécurisée avec votre visage ou votre empreinte digitale. Aucun mot de passe ou code requis.', }, + revoke: { + revoke: 'Révoquer', + title: 'Reconnaissance faciale/empreinte digitale et passkeys', + explanation: + 'La vérification par reconnaissance faciale/empreinte digitale ou par passkey est activée sur un ou plusieurs appareils. Révoquer l’accès exigera un code magique pour la prochaine vérification sur n’importe quel appareil', + confirmationPrompt: 'Êtes-vous sûr ? Vous aurez besoin d’un code magique pour la prochaine vérification sur n’importe quel appareil', + cta: 'Révoquer l’accès', + noDevices: + 'Vous n’avez enregistré aucun appareil pour la vérification par reconnaissance faciale/empreinte digitale ou par passkey. Si vous en enregistrez, vous pourrez révoquer cet accès ici.', + dismiss: 'Compris', + error: 'La requête a échoué. Veuillez réessayer plus tard.', + }, }, validateCodeModal: { successfulSignInTitle: dedent(` @@ -784,7 +800,7 @@ const translations: TranslationDeepObject = { expiredCodeDescription: 'Retournez sur l’appareil d’origine et demandez un nouveau code', successfulNewCodeRequest: 'Code demandé. Veuillez vérifier votre appareil.', tfaRequiredTitle: dedent(` - Authentification à deux facteurs + Authentification à deux facteurs requise `), tfaRequiredDescription: dedent(` @@ -917,6 +933,7 @@ const translations: TranslationDeepObject = { beginningOfChatHistorySelfDM: 'Ceci est votre espace personnel. Utilisez-le pour vos notes, tâches, brouillons et rappels.', beginningOfChatHistorySystemDM: 'Bienvenue ! Commençons votre configuration.', chatWithAccountManager: 'Discutez avec votre gestionnaire de compte ici', + askMeAnything: 'Posez-moi toutes vos questions !', sayHello: 'Dites bonjour !', yourSpace: 'Votre espace', welcomeToRoom: ({roomName}: WelcomeToRoomParams) => `Bienvenue dans ${roomName} !`, @@ -1490,6 +1507,32 @@ const translations: TranslationDeepObject = { }, correctDistanceRateError: 'Corrigez l’erreur de taux de distance et réessayez.', AskToExplain: `. Expliquer ✨`, + policyRulesModifiedFields: (policyRulesModifiedFields: PolicyRulesModifiedFields, policyRulesRoute: string, formatList: (list: string[]) => string) => { + const entries = ObjectUtils.typedEntries(policyRulesModifiedFields); + const fragments = entries.map(([key, value], i) => { + const isFirst = i === 0; + if (key === 'reimbursable') { + return value ? 'a marqué la dépense comme « remboursable »' : 'a marqué la dépense comme « non remboursable »'; + } + if (key === 'billable') { + return value ? 'a marqué la dépense comme « facturable »' : 'a marqué la dépense comme « non refacturable »'; + } + if (key === 'tax') { + const taxEntry = value as PolicyRulesModifiedFields['tax']; + const taxRateName = taxEntry?.field_id_TAX.name ?? ''; + if (isFirst) { + return `définir le taux de taxe sur « ${taxRateName} »`; + } + return `taux de taxe vers « ${taxRateName} »`; + } + const updatedValue = value as string | boolean; + if (isFirst) { + return `définir ${translations.common[key].toLowerCase()} sur « ${updatedValue} »`; + } + return `${translations.common[key].toLowerCase()} à « ${updatedValue} »`; + }); + return `${formatList(fragments)} via les règles de l’espace de travail`; + }, }, transactionMerge: { listPage: { @@ -4659,7 +4702,7 @@ _Pour des instructions plus détaillées, [visitez notre site d’aide](${CONST. _Pour des instructions plus détaillées, [visitez notre site d’aide](${CONST.NETSUITE_IMPORT.HELP_LINKS.CUSTOM_SEGMENTS})_.`, customSegmentScriptIDTitle: 'Quel est l’ID du script ?', - customSegmentScriptIDFooter: `Vous pouvez trouver les ID de script de segment personnalisé dans NetSuite sous : + customSegmentScriptIDFooter: `Vous pouvez trouver les ID de script de segment personnalisé dans NetSuite sous : 1. *Customization > Lists, Records, & Fields > Custom Segments*. 2. Cliquez sur un segment personnalisé. @@ -6352,6 +6395,17 @@ Exigez des informations de dépense comme les reçus et les descriptions, défin ruleSummarySubtitleUpdateField: (fieldName: string, fieldValue: string) => `Mettre à jour ${fieldName} sur « ${fieldValue} »`, ruleSummarySubtitleReimbursable: (reimbursable: boolean) => `Marquer comme « ${reimbursable ? 'remboursable' : 'non remboursable'} »`, ruleSummarySubtitleBillable: (billable: boolean) => `Marquer comme « ${billable ? 'facturable' : 'non facturable'} »`, + addRuleTitle: 'Ajouter une règle', + expensesWith: 'Pour les dépenses avec :', + applyUpdates: 'Appliquer ces mises à jour :', + merchantHint: 'Faire correspondre un nom de commerçant avec une correspondance « contient » insensible à la casse', + saveRule: 'Enregistrer la règle', + confirmError: 'Saisissez un marchand et appliquez au moins une mise à jour', + confirmErrorMerchant: 'Veuillez saisir le commerçant', + confirmErrorUpdate: 'Veuillez appliquer au moins une mise à jour', + editRuleTitle: 'Modifier la règle', + deleteRule: 'Supprimer la règle', + deleteRuleConfirmation: 'Voulez-vous vraiment supprimer cette règle ?', }, }, planTypePage: { @@ -6909,8 +6963,9 @@ Exigez des informations de dépense comme les reçus et les descriptions, défin on: (date?: string) => `Le ${date ?? ''}`, presets: { [CONST.SEARCH.DATE_PRESETS.NEVER]: 'Jamais', - [CONST.SEARCH.DATE_PRESETS.LAST_MONTH]: 'Le mois dernier', - [CONST.SEARCH.DATE_PRESETS.THIS_MONTH]: 'Ce mois-ci', + [CONST.SEARCH.DATE_PRESETS.LAST_MONTH]: 'Mois dernier', + [CONST.SEARCH.DATE_PRESETS.THIS_MONTH]: 'Ce mois-ci', //_/\__/_/ \_,_/\__/\__/\_,_/ + [CONST.SEARCH.DATE_PRESETS.YEAR_TO_DATE]: 'Année à ce jour', [CONST.SEARCH.DATE_PRESETS.LAST_STATEMENT]: 'Dernier relevé', }, }, @@ -6952,6 +7007,8 @@ Exigez des informations de dépense comme les reçus et les descriptions, défin [CONST.SEARCH.GROUP_BY.CARD]: 'Carte', [CONST.SEARCH.GROUP_BY.WITHDRAWAL_ID]: 'ID de retrait', [CONST.SEARCH.GROUP_BY.CATEGORY]: 'Catégorie', + [CONST.SEARCH.GROUP_BY.TAG]: 'Étiquette', + [CONST.SEARCH.GROUP_BY.MONTH]: 'Mois', }, feed: 'Flux', withdrawalType: { diff --git a/src/languages/it.ts b/src/languages/it.ts index 4c28dfb3d207..8ef5103b5b7d 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -17,6 +17,8 @@ import dedent from '@libs/StringUtils/dedent'; import CONST from '@src/CONST'; import type {Country} from '@src/CONST'; import type OriginalMessage from '@src/types/onyx/OriginalMessage'; +import {PolicyRulesModifiedFields} from '@src/types/onyx/OriginalMessage'; +import ObjectUtils from '@src/types/utils/ObjectUtils'; import type en from './en'; import type { ChangeFieldParams, @@ -638,6 +640,8 @@ const translations: TranslationDeepObject = { insights: 'Analisi', duplicateExpense: 'Spesa duplicata', newFeature: 'Nuova funzionalità', + month: 'Mese', + home: 'Home', }, supportalNoAccess: { title: 'Non così in fretta', @@ -760,6 +764,17 @@ const translations: TranslationDeepObject = { enableQuickVerification: { biometrics: 'Abilita una verifica rapida e sicura utilizzando il tuo viso o impronta digitale. Nessuna password o codice necessario.', }, + revoke: { + revoke: 'Revoca', + title: 'Riconoscimento facciale/impronta digitale & passkey', + explanation: + 'La verifica con volto/impronta digitale o passkey è abilitata su uno o più dispositivi. La revoca dell’accesso richiederà un codice magico per la prossima verifica su qualsiasi dispositivo', + confirmationPrompt: 'Sei sicuro? Ti servirà un codice magico per la prossima verifica su qualsiasi dispositivo', + cta: 'Revoca accesso', + noDevices: 'Non hai alcun dispositivo registrato per la verifica con volto/impronta digitale o passkey. Se ne registri uno, potrai revocare tale accesso da qui.', + dismiss: 'Ho capito', + error: 'Richiesta non riuscita. Riprova più tardi.', + }, }, validateCodeModal: { successfulSignInTitle: dedent(` @@ -782,7 +797,7 @@ const translations: TranslationDeepObject = { expiredCodeDescription: 'Torna al dispositivo originale e richiedi un nuovo codice', successfulNewCodeRequest: 'Codice richiesto. Controlla il tuo dispositivo.', tfaRequiredTitle: dedent(` - Autenticazione a due fattori + Autenticazione a due fattori richiesta `), tfaRequiredDescription: dedent(` @@ -914,6 +929,7 @@ const translations: TranslationDeepObject = { beginningOfChatHistorySelfDM: 'Questo è il tuo spazio personale. Usalo per note, attività, bozze e promemoria.', beginningOfChatHistorySystemDM: 'Benvenuto! Configuriamo il tuo account.', chatWithAccountManager: 'Chatta qui con il tuo account manager', + askMeAnything: 'Chiedimi qualsiasi cosa!', sayHello: "Di' ciao!", yourSpace: 'Il tuo spazio', welcomeToRoom: ({roomName}: WelcomeToRoomParams) => `Benvenuto in ${roomName}!`, @@ -1484,6 +1500,32 @@ const translations: TranslationDeepObject = { }, correctDistanceRateError: "Correggi l'errore nella tariffa della distanza e riprova.", AskToExplain: `. Spiega ✨`, + policyRulesModifiedFields: (policyRulesModifiedFields: PolicyRulesModifiedFields, policyRulesRoute: string, formatList: (list: string[]) => string) => { + const entries = ObjectUtils.typedEntries(policyRulesModifiedFields); + const fragments = entries.map(([key, value], i) => { + const isFirst = i === 0; + if (key === 'reimbursable') { + return value ? 'ha contrassegnato la spesa come "rimborsabile"' : 'ha contrassegnato la spesa come "non rimborsabile"'; + } + if (key === 'billable') { + return value ? 'ha contrassegnato la spesa come "fatturabile"' : 'ha contrassegnato la spesa come "non addebitabile"'; + } + if (key === 'tax') { + const taxEntry = value as PolicyRulesModifiedFields['tax']; + const taxRateName = taxEntry?.field_id_TAX.name ?? ''; + if (isFirst) { + return `imposta l'aliquota fiscale su "${taxRateName}"`; + } + return `aliquota fiscale in "${taxRateName}"`; + } + const updatedValue = value as string | boolean; + if (isFirst) { + return `imposta ${translations.common[key].toLowerCase()} su "${updatedValue}"`; + } + return `${translations.common[key].toLowerCase()} a "${updatedValue}"`; + }); + return `${formatList(fragments)} tramite regole dello spazio di lavoro`; + }, }, transactionMerge: { listPage: { @@ -6325,6 +6367,17 @@ Richiedi dettagli di spesa come ricevute e descrizioni, imposta limiti e valori ruleSummarySubtitleUpdateField: (fieldName: string, fieldValue: string) => `Aggiorna ${fieldName} a "${fieldValue}"`, ruleSummarySubtitleReimbursable: (reimbursable: boolean) => `Segna come "${reimbursable ? 'rimborsabile' : 'non rimborsabile'}"`, ruleSummarySubtitleBillable: (billable: boolean) => `Contrassegna come "${billable ? 'fatturabile' : 'non fatturabile'}"`, + addRuleTitle: 'Aggiungi regola', + expensesWith: 'Per spese con:', + applyUpdates: 'Applica questi aggiornamenti:', + merchantHint: 'Abbina un nome commerciante con una corrispondenza "contiene" che non distingue tra maiuscole e minuscole', + saveRule: 'Salva regola', + confirmError: 'Inserisci l’esercente e applica almeno un aggiornamento', + confirmErrorMerchant: 'Per favore inserisci l’esercente', + confirmErrorUpdate: 'Applica almeno un aggiornamento', + editRuleTitle: 'Modifica regola', + deleteRule: 'Elimina regola', + deleteRuleConfirmation: 'Sei sicuro di voler eliminare questa regola?', }, }, planTypePage: { @@ -6888,8 +6941,9 @@ Richiedi dettagli di spesa come ricevute e descrizioni, imposta limiti e valori on: (date?: string) => `Su ${date ?? ''}`, presets: { [CONST.SEARCH.DATE_PRESETS.NEVER]: 'Mai', - [CONST.SEARCH.DATE_PRESETS.LAST_MONTH]: 'Il mese scorso', + [CONST.SEARCH.DATE_PRESETS.LAST_MONTH]: 'Lo scorso mese', [CONST.SEARCH.DATE_PRESETS.THIS_MONTH]: 'Questo mese', + [CONST.SEARCH.DATE_PRESETS.YEAR_TO_DATE]: 'Da inizio anno', [CONST.SEARCH.DATE_PRESETS.LAST_STATEMENT]: 'Ultimo estratto conto', }, }, @@ -6929,8 +6983,10 @@ Richiedi dettagli di spesa come ricevute e descrizioni, imposta limiti e valori groupBy: { [CONST.SEARCH.GROUP_BY.FROM]: 'Da', [CONST.SEARCH.GROUP_BY.CARD]: 'Carta', - [CONST.SEARCH.GROUP_BY.WITHDRAWAL_ID]: 'ID prelievo', + [CONST.SEARCH.GROUP_BY.WITHDRAWAL_ID]: 'ID prelievo', //_/\__/_/ \_,_/\__/\__/\_,_/ [CONST.SEARCH.GROUP_BY.CATEGORY]: 'Categoria', + [CONST.SEARCH.GROUP_BY.TAG]: 'Etichetta', + [CONST.SEARCH.GROUP_BY.MONTH]: 'Mese', }, feed: 'Feed', withdrawalType: { diff --git a/src/languages/ja.ts b/src/languages/ja.ts index 799872b3e035..29398886299c 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -17,6 +17,8 @@ import dedent from '@libs/StringUtils/dedent'; import CONST from '@src/CONST'; import type {Country} from '@src/CONST'; import type OriginalMessage from '@src/types/onyx/OriginalMessage'; +import {PolicyRulesModifiedFields} from '@src/types/onyx/OriginalMessage'; +import ObjectUtils from '@src/types/utils/ObjectUtils'; import type en from './en'; import type { ChangeFieldParams, @@ -637,6 +639,8 @@ const translations: TranslationDeepObject = { insights: 'インサイト', duplicateExpense: '重複した経費', newFeature: '新機能', + month: '月', + home: 'ホーム', }, supportalNoAccess: { title: 'ちょっと待ってください', @@ -759,6 +763,16 @@ const translations: TranslationDeepObject = { enableQuickVerification: { biometrics: '顔または指紋を使用して、パスワードやコード不要の迅速かつ安全な認証を有効にしてください。', }, + revoke: { + revoke: '取り消す', + title: '顔/指紋 & パスキー', + explanation: '1 台以上のデバイスで顔 / 指紋またはパスキー認証が有効になっています。アクセスを取り消すと、今後どのデバイスでも次回の認証時にマジックコードが必要になります', + confirmationPrompt: '本当に実行しますか?次回、どのデバイスで確認する場合でも、マジックコードが必要になります', + cta: 'アクセスを取り消す', + noDevices: '顔認証 / 指紋認証 またはパスキー認証用に登録されているデバイスがありません。 \nいずれかを登録すると、ここでそのアクセスを取り消せるようになります。', + dismiss: '了解', + error: 'リクエストに失敗しました。後でもう一度お試しください。', + }, }, validateCodeModal: { successfulSignInTitle: dedent(` @@ -912,6 +926,7 @@ const translations: TranslationDeepObject = { beginningOfChatHistorySelfDM: 'これはあなたの個人スペースです。メモ、タスク、下書き、リマインダーとして使用してください。', beginningOfChatHistorySystemDM: 'ようこそ!さっそく設定を始めましょう。', chatWithAccountManager: 'ここでアカウントマネージャーとチャットする', + askMeAnything: '何でも聞いてください!', sayHello: 'こんにちはと言ってください!', yourSpace: 'あなたのスペース', welcomeToRoom: ({roomName}: WelcomeToRoomParams) => `${roomName} へようこそ!`, @@ -1482,6 +1497,32 @@ const translations: TranslationDeepObject = { }, correctDistanceRateError: '距離レートのエラーを修正して、もう一度お試しください。', AskToExplain: `. 説明する ✨`, + policyRulesModifiedFields: (policyRulesModifiedFields: PolicyRulesModifiedFields, policyRulesRoute: string, formatList: (list: string[]) => string) => { + const entries = ObjectUtils.typedEntries(policyRulesModifiedFields); + const fragments = entries.map(([key, value], i) => { + const isFirst = i === 0; + if (key === 'reimbursable') { + return value ? '経費を「立替精算対象」にマークしました' : '経費を「非精算」としてマークしました'; + } + if (key === 'billable') { + return value ? '経費を「請求可能」とマークしました' : '経費を「請求対象外」としてマークしました'; + } + if (key === 'tax') { + const taxEntry = value as PolicyRulesModifiedFields['tax']; + const taxRateName = taxEntry?.field_id_TAX.name ?? ''; + if (isFirst) { + return `税率を「${taxRateName}」に設定`; + } + return `税率を「${taxRateName}」に`; + } + const updatedValue = value as string | boolean; + if (isFirst) { + return `${translations.common[key].toLowerCase()} を「${updatedValue}」に設定`; + } + return `${translations.common[key].toLowerCase()} を「${updatedValue}」に`; + }); + return `${formatList(fragments)}(ワークスペースルール経由)`; + }, }, transactionMerge: { listPage: { @@ -6282,6 +6323,17 @@ ${reportName} ruleSummarySubtitleUpdateField: (fieldName: string, fieldValue: string) => `${fieldName} を「${fieldValue}」に更新`, ruleSummarySubtitleReimbursable: (reimbursable: boolean) => `「${reimbursable ? '払い戻し対象' : '精算対象外'}」としてマーク`, ruleSummarySubtitleBillable: (billable: boolean) => `「${billable ? '請求可能' : '請求対象外'}」としてマーク`, + addRuleTitle: 'ルールを追加', + expensesWith: '次の条件の経費について:', + applyUpdates: 'これらの更新を適用:', + merchantHint: '大文字小文字を区別しない「含む」一致で支払先名を照合する', + saveRule: 'ルールを保存', + confirmError: '支払先を入力し、少なくとも 1 つの更新を適用してください', + confirmErrorMerchant: '商人を入力してください', + confirmErrorUpdate: '少なくとも 1 件の更新を適用してください', + editRuleTitle: 'ルールを編集', + deleteRule: 'ルールを削除', + deleteRuleConfirmation: 'このルールを削除してもよろしいですか?', }, }, planTypePage: { @@ -6831,7 +6883,8 @@ ${reportName} [CONST.SEARCH.DATE_PRESETS.NEVER]: 'しない', [CONST.SEARCH.DATE_PRESETS.LAST_MONTH]: '先月', [CONST.SEARCH.DATE_PRESETS.THIS_MONTH]: '今月', - [CONST.SEARCH.DATE_PRESETS.LAST_STATEMENT]: '最新の明細', + [CONST.SEARCH.DATE_PRESETS.YEAR_TO_DATE]: '年初来', + [CONST.SEARCH.DATE_PRESETS.LAST_STATEMENT]: '最新の明細書', }, }, status: 'ステータス', @@ -6868,10 +6921,12 @@ ${reportName} reimbursable: '精算対象', purchaseCurrency: '購入通貨', groupBy: { - [CONST.SEARCH.GROUP_BY.FROM]: '差出人', + [CONST.SEARCH.GROUP_BY.FROM]: '送信者', [CONST.SEARCH.GROUP_BY.CARD]: 'カード', [CONST.SEARCH.GROUP_BY.WITHDRAWAL_ID]: '出金ID', - [CONST.SEARCH.GROUP_BY.CATEGORY]: 'カテゴリー', + [CONST.SEARCH.GROUP_BY.CATEGORY]: 'カテゴリ', + [CONST.SEARCH.GROUP_BY.TAG]: 'タグ', + [CONST.SEARCH.GROUP_BY.MONTH]: '月', }, feed: 'フィード', withdrawalType: { diff --git a/src/languages/nl.ts b/src/languages/nl.ts index d86ae80b8bd5..a29f8bacdf33 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -17,6 +17,8 @@ import dedent from '@libs/StringUtils/dedent'; import CONST from '@src/CONST'; import type {Country} from '@src/CONST'; import type OriginalMessage from '@src/types/onyx/OriginalMessage'; +import {PolicyRulesModifiedFields} from '@src/types/onyx/OriginalMessage'; +import ObjectUtils from '@src/types/utils/ObjectUtils'; import type en from './en'; import type { ChangeFieldParams, @@ -638,6 +640,8 @@ const translations: TranslationDeepObject = { insights: 'Inzichten', duplicateExpense: 'Dubbele uitgave', newFeature: 'Nieuwe functie', + month: 'Maand', + home: 'Start', }, supportalNoAccess: { title: 'Niet zo snel', @@ -760,6 +764,17 @@ const translations: TranslationDeepObject = { enableQuickVerification: { biometrics: 'Schakel snelle, veilige verificatie in met je gezicht of vingerafdruk. Geen wachtwoorden of codes nodig.', }, + revoke: { + revoke: 'Intrekken', + title: 'Gezicht/vingerafdruk en passkeys', + explanation: + 'Gezichts-/vingerafdruk- of passkeys-verificatie is ingeschakeld op één of meer apparaten. Toegang intrekken vereist een magische code voor de volgende verificatie op elk apparaat', + confirmationPrompt: 'Weet je het zeker? Je hebt een magische code nodig voor de volgende verificatie op elk apparaat', + cta: 'Toegang intrekken', + noDevices: 'Je hebt geen apparaten geregistreerd voor gezichts-/vingerafdruk- of passkey-verificatie. Als je er een registreert, kun je die toegang hier intrekken.', + dismiss: 'Begrepen', + error: 'Aanvraag mislukt. Probeer het later opnieuw.', + }, }, validateCodeModal: { successfulSignInTitle: dedent(` @@ -914,6 +929,7 @@ const translations: TranslationDeepObject = { beginningOfChatHistorySelfDM: 'Dit is je persoonlijke ruimte. Gebruik het voor notities, taken, concepten en herinneringen.', beginningOfChatHistorySystemDM: 'Welkom! Laten we je instellen.', chatWithAccountManager: 'Chat hier met je accountmanager', + askMeAnything: 'Vraag mij wat je maar wilt!', sayHello: 'Zeg hallo!', yourSpace: 'Je ruimte', welcomeToRoom: ({roomName}: WelcomeToRoomParams) => `Welkom bij ${roomName}!`, @@ -1483,6 +1499,32 @@ const translations: TranslationDeepObject = { }, correctDistanceRateError: 'Los het foutieve kilometertarief op en probeer het opnieuw.', AskToExplain: `. Uitleggen ✨`, + policyRulesModifiedFields: (policyRulesModifiedFields: PolicyRulesModifiedFields, policyRulesRoute: string, formatList: (list: string[]) => string) => { + const entries = ObjectUtils.typedEntries(policyRulesModifiedFields); + const fragments = entries.map(([key, value], i) => { + const isFirst = i === 0; + if (key === 'reimbursable') { + return value ? 'heeft de uitgave als „vergoedbaar” gemarkeerd' : 'markeerde de uitgave als ‘niet-terugbetaalbaar’'; + } + if (key === 'billable') { + return value ? 'heeft de uitgave gemarkeerd als ‘factureerbaar’' : 'heeft de uitgave als ‘niet-declarabel’ gemarkeerd'; + } + if (key === 'tax') { + const taxEntry = value as PolicyRulesModifiedFields['tax']; + const taxRateName = taxEntry?.field_id_TAX.name ?? ''; + if (isFirst) { + return `stel het belastingtarief in op "${taxRateName}"`; + } + return `belastingtarief naar "${taxRateName}"`; + } + const updatedValue = value as string | boolean; + if (isFirst) { + return `stel de ${translations.common[key].toLowerCase()} in op "${updatedValue}"`; + } + return `${translations.common[key].toLowerCase()} naar "${updatedValue}"`; + }); + return `${formatList(fragments)} via werkruimteregels`; + }, }, transactionMerge: { listPage: { @@ -6312,6 +6354,17 @@ Vraag verplichte uitgavedetails zoals bonnetjes en beschrijvingen, stel limieten ruleSummarySubtitleUpdateField: (fieldName: string, fieldValue: string) => `Werk ${fieldName} bij naar "${fieldValue}"`, ruleSummarySubtitleReimbursable: (reimbursable: boolean) => `Markeren als "${reimbursable ? 'Vergoedbaar' : 'niet-vergoedbaar'}"`, ruleSummarySubtitleBillable: (billable: boolean) => `Markeren als "${billable ? 'factureerbaar' : 'niet-factureerbaar'}"`, + addRuleTitle: 'Regel toevoegen', + expensesWith: 'Voor uitgaven met:', + applyUpdates: 'Deze updates toepassen:', + merchantHint: 'Een handelsnaam koppelen met hoofdletterongevoelige "bevat"-overeenkomst', + saveRule: 'Regel opslaan', + confirmError: 'Voer een leverancier in en pas ten minste één wijziging toe', + confirmErrorMerchant: 'Voer handelaar in', + confirmErrorUpdate: 'Breng ten minste één wijziging aan alstublieft', + editRuleTitle: 'Regel bewerken', + deleteRule: 'Regel verwijderen', + deleteRuleConfirmation: 'Weet je zeker dat je deze regel wilt verwijderen?', }, }, planTypePage: { @@ -6872,6 +6925,7 @@ Vraag verplichte uitgavedetails zoals bonnetjes en beschrijvingen, stel limieten [CONST.SEARCH.DATE_PRESETS.NEVER]: 'Nooit', [CONST.SEARCH.DATE_PRESETS.LAST_MONTH]: 'Vorige maand', [CONST.SEARCH.DATE_PRESETS.THIS_MONTH]: 'Deze maand', + [CONST.SEARCH.DATE_PRESETS.YEAR_TO_DATE]: 'Jaar tot nu toe', [CONST.SEARCH.DATE_PRESETS.LAST_STATEMENT]: 'Laatste afschrift', }, }, @@ -6913,6 +6967,8 @@ Vraag verplichte uitgavedetails zoals bonnetjes en beschrijvingen, stel limieten [CONST.SEARCH.GROUP_BY.CARD]: 'Kaart', [CONST.SEARCH.GROUP_BY.WITHDRAWAL_ID]: 'Opname-ID', [CONST.SEARCH.GROUP_BY.CATEGORY]: 'Categorie', + [CONST.SEARCH.GROUP_BY.TAG]: 'Label', + [CONST.SEARCH.GROUP_BY.MONTH]: 'Maand', }, feed: 'Feed', withdrawalType: { diff --git a/src/languages/pl.ts b/src/languages/pl.ts index ad343fba20a7..67b0050f7a51 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -17,6 +17,8 @@ import dedent from '@libs/StringUtils/dedent'; import CONST from '@src/CONST'; import type {Country} from '@src/CONST'; import type OriginalMessage from '@src/types/onyx/OriginalMessage'; +import {PolicyRulesModifiedFields} from '@src/types/onyx/OriginalMessage'; +import ObjectUtils from '@src/types/utils/ObjectUtils'; import type en from './en'; import type { ChangeFieldParams, @@ -638,6 +640,8 @@ const translations: TranslationDeepObject = { insights: 'Analizy', duplicateExpense: 'Zduplikowany wydatek', newFeature: 'Nowa funkcja', + month: 'Miesiąc', + home: 'Strona główna', }, supportalNoAccess: { title: 'Nie tak szybko', @@ -760,6 +764,18 @@ const translations: TranslationDeepObject = { enableQuickVerification: { biometrics: 'Włącz szybką i bezpieczną weryfikację za pomocą twarzy lub odcisku palca. Bez haseł ani kodów.', }, + revoke: { + revoke: 'Unieważnij', + title: 'Rozpoznawanie twarzy/odcisk palca i klucze dostępu', + explanation: + 'Weryfikacja za pomocą twarzy/odcisku palca lub klucza dostępu (passkey) jest włączona na jednym lub większej liczbie urządzeń. Cofnięcie dostępu spowoduje, że przy następnej weryfikacji na dowolnym urządzeniu wymagany będzie magiczny kod', + confirmationPrompt: 'Czy na pewno? Będziesz potrzebować magicznego kodu do następnej weryfikacji na dowolnym urządzeniu', + cta: 'Cofnij dostęp', + noDevices: + 'Nie masz żadnych urządzeń zarejestrowanych do weryfikacji twarzą, odciskiem palca ani kluczem dostępu. Jeśli jakieś zarejestrujesz, będziesz mógł/mogła cofnąć ten dostęp w tym miejscu.', + dismiss: 'Rozumiem', + error: 'Żądanie nie powiodło się. Spróbuj ponownie później.', + }, }, validateCodeModal: { successfulSignInTitle: dedent(` @@ -913,6 +929,7 @@ const translations: TranslationDeepObject = { beginningOfChatHistorySelfDM: 'To jest Twoja osobista przestrzeń. Używaj jej do notatek, zadań, szkiców i przypomnień.', beginningOfChatHistorySystemDM: 'Witamy! Skonfigurujmy wszystko.', chatWithAccountManager: 'Czat z Twoim opiekunem klienta tutaj', + askMeAnything: 'Zapytaj mnie o cokolwiek!', sayHello: 'Przywitaj się!', yourSpace: 'Twoja przestrzeń', welcomeToRoom: ({roomName}: WelcomeToRoomParams) => `Witamy w ${roomName}!`, @@ -1482,6 +1499,32 @@ const translations: TranslationDeepObject = { }, correctDistanceRateError: 'Napraw błąd stawki za dystans i spróbuj ponownie.', AskToExplain: `. Wyjaśnij ✨`, + policyRulesModifiedFields: (policyRulesModifiedFields: PolicyRulesModifiedFields, policyRulesRoute: string, formatList: (list: string[]) => string) => { + const entries = ObjectUtils.typedEntries(policyRulesModifiedFields); + const fragments = entries.map(([key, value], i) => { + const isFirst = i === 0; + if (key === 'reimbursable') { + return value ? 'oznaczył wydatek jako „podlegający zwrotowi”' : 'oznaczył wydatek jako „niepodlegający zwrotowi”'; + } + if (key === 'billable') { + return value ? 'oznaczył wydatek jako „refakturowalny”' : 'oznaczył wydatek jako „niepodlegający refakturowaniu”'; + } + if (key === 'tax') { + const taxEntry = value as PolicyRulesModifiedFields['tax']; + const taxRateName = taxEntry?.field_id_TAX.name ?? ''; + if (isFirst) { + return `ustaw stawkę podatku na „${taxRateName}”`; + } + return `stawka podatku na „${taxRateName}”`; + } + const updatedValue = value as string | boolean; + if (isFirst) { + return `ustaw ${translations.common[key].toLowerCase()} na „${updatedValue}”`; + } + return `${translations.common[key].toLowerCase()} na „${updatedValue}”`; + }); + return `${formatList(fragments)} przez zasady przestrzeni roboczej`; + }, }, transactionMerge: { listPage: { @@ -6305,6 +6348,17 @@ Wymagaj szczegółów wydatków, takich jak paragony i opisy, ustawiaj limity i ruleSummarySubtitleUpdateField: (fieldName: string, fieldValue: string) => `Zaktualizuj ${fieldName} na „${fieldValue}”`, ruleSummarySubtitleReimbursable: (reimbursable: boolean) => `Oznacz jako "${reimbursable ? 'kwalifikujący się do zwrotu kosztów' : 'niepodlegający zwrotowi'}"`, ruleSummarySubtitleBillable: (billable: boolean) => `Oznacz jako „${billable ? 'fakturowalne' : 'poza fakturą'}”`, + addRuleTitle: 'Dodaj regułę', + expensesWith: 'Dla wydatków z:', + applyUpdates: 'Zastosuj te aktualizacje:', + merchantHint: 'Dopasuj nazwę sprzedawcy przy użyciu nieczułego na wielkość liter dopasowania typu „zawiera”', + saveRule: 'Zapisz regułę', + confirmError: 'Wprowadź sprzedawcę i zastosuj co najmniej jedną aktualizację', + confirmErrorMerchant: 'Wprowadź sprzedawcę', + confirmErrorUpdate: 'Proszę wprowadzić co najmniej jedną zmianę', + editRuleTitle: 'Edytuj regułę', + deleteRule: 'Usuń regułę', + deleteRuleConfirmation: 'Czy na pewno chcesz usunąć tę regułę?', }, }, planTypePage: { @@ -6861,6 +6915,7 @@ Wymagaj szczegółów wydatków, takich jak paragony i opisy, ustawiaj limity i [CONST.SEARCH.DATE_PRESETS.NEVER]: 'Nigdy', [CONST.SEARCH.DATE_PRESETS.LAST_MONTH]: 'W zeszłym miesiącu', [CONST.SEARCH.DATE_PRESETS.THIS_MONTH]: 'Ten miesiąc', + [CONST.SEARCH.DATE_PRESETS.YEAR_TO_DATE]: 'Od początku roku', [CONST.SEARCH.DATE_PRESETS.LAST_STATEMENT]: 'Ostatnie zestawienie', }, }, @@ -6902,6 +6957,8 @@ Wymagaj szczegółów wydatków, takich jak paragony i opisy, ustawiaj limity i [CONST.SEARCH.GROUP_BY.CARD]: 'Karta', [CONST.SEARCH.GROUP_BY.WITHDRAWAL_ID]: 'ID wypłaty', [CONST.SEARCH.GROUP_BY.CATEGORY]: 'Kategoria', + [CONST.SEARCH.GROUP_BY.TAG]: 'Etykieta', + [CONST.SEARCH.GROUP_BY.MONTH]: 'Miesiąc', }, feed: 'Kanał', withdrawalType: { diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index 28391858cf31..6251c0b90fe7 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -17,6 +17,8 @@ import dedent from '@libs/StringUtils/dedent'; import CONST from '@src/CONST'; import type {Country} from '@src/CONST'; import type OriginalMessage from '@src/types/onyx/OriginalMessage'; +import {PolicyRulesModifiedFields} from '@src/types/onyx/OriginalMessage'; +import ObjectUtils from '@src/types/utils/ObjectUtils'; import type en from './en'; import type { ChangeFieldParams, @@ -637,6 +639,8 @@ const translations: TranslationDeepObject = { insights: 'Insights', duplicateExpense: 'Despesa duplicada', newFeature: 'Novo recurso', + month: 'Mês', + home: 'Início', }, supportalNoAccess: { title: 'Não tão rápido', @@ -759,6 +763,17 @@ const translations: TranslationDeepObject = { enableQuickVerification: { biometrics: 'Habilite a verificação rápida e segura usando seu rosto ou impressão digital. Sem senhas ou códigos necessários.', }, + revoke: { + revoke: 'Revogar', + title: 'Rosto/digital & chaves de acesso', + explanation: + 'A verificação por rosto/digital ou chave de acesso está ativada em um ou mais dispositivos. Revogar o acesso exigirá um código mágico para a próxima verificação em qualquer dispositivo', + confirmationPrompt: 'Tem certeza? Você precisará de um código mágico para a próxima verificação em qualquer dispositivo', + cta: 'Revogar acesso', + noDevices: 'Você não tem nenhum dispositivo registrado para verificação por rosto/digital ou passkey. Se você registrar algum, poderá revogar esse acesso aqui.', + dismiss: 'Entendi', + error: 'Falha na solicitação. Tente novamente mais tarde.', + }, }, validateCodeModal: { successfulSignInTitle: dedent(` @@ -913,6 +928,7 @@ const translations: TranslationDeepObject = { beginningOfChatHistorySelfDM: 'Este é o seu espaço pessoal. Use-o para anotações, tarefas, rascunhos e lembretes.', beginningOfChatHistorySystemDM: 'Bem-vindo(a)! Vamos configurar tudo para você.', chatWithAccountManager: 'Converse com o seu gerente de conta aqui', + askMeAnything: 'Pergunte-me qualquer coisa!', sayHello: 'Diga olá!', yourSpace: 'Seu espaço', welcomeToRoom: ({roomName}: WelcomeToRoomParams) => `Bem-vindo(a) a ${roomName}!`, @@ -1480,6 +1496,32 @@ const translations: TranslationDeepObject = { }, correctDistanceRateError: 'Corrija o erro na taxa de distância e tente novamente.', AskToExplain: `. Explicar ✨`, + policyRulesModifiedFields: (policyRulesModifiedFields: PolicyRulesModifiedFields, policyRulesRoute: string, formatList: (list: string[]) => string) => { + const entries = ObjectUtils.typedEntries(policyRulesModifiedFields); + const fragments = entries.map(([key, value], i) => { + const isFirst = i === 0; + if (key === 'reimbursable') { + return value ? 'marcou a despesa como "reembolsável"' : 'marcou a despesa como “não reembolsável”'; + } + if (key === 'billable') { + return value ? 'marcou a despesa como “faturável”' : 'marcou a despesa como "não faturável"'; + } + if (key === 'tax') { + const taxEntry = value as PolicyRulesModifiedFields['tax']; + const taxRateName = taxEntry?.field_id_TAX.name ?? ''; + if (isFirst) { + return `definir a taxa de imposto como "${taxRateName}"`; + } + return `alíquota de imposto para "${taxRateName}"`; + } + const updatedValue = value as string | boolean; + if (isFirst) { + return `defina o ${translations.common[key].toLowerCase()} como "${updatedValue}"`; + } + return `${translations.common[key].toLowerCase()} para "${updatedValue}"`; + }); + return `${formatList(fragments)} via regras do workspace`; + }, }, transactionMerge: { listPage: { @@ -6306,6 +6348,17 @@ Exija detalhes de despesas como recibos e descrições, defina limites e padrõe ruleSummarySubtitleUpdateField: (fieldName: string, fieldValue: string) => `Atualizar ${fieldName} para "${fieldValue}"`, ruleSummarySubtitleReimbursable: (reimbursable: boolean) => `Marcar como "${reimbursable ? 'reembolsável' : 'não reembolsável'}"`, ruleSummarySubtitleBillable: (billable: boolean) => `Marcar como "${billable ? 'faturável' : 'não faturável'}"`, + addRuleTitle: 'Adicionar regra', + expensesWith: 'Para despesas com:', + applyUpdates: 'Aplicar estas atualizações:', + merchantHint: 'Corresponder um nome de comerciante com correspondência "contém" sem diferenciação entre maiúsculas e minúsculas', + saveRule: 'Salvar regra', + confirmError: 'Insira o estabelecimento e aplique pelo menos uma atualização', + confirmErrorMerchant: 'Insira o comerciante', + confirmErrorUpdate: 'Por favor, aplique pelo menos uma atualização', + editRuleTitle: 'Editar regra', + deleteRule: 'Excluir regra', + deleteRuleConfirmation: 'Tem certeza de que deseja excluir esta regra?', }, }, planTypePage: { @@ -6863,6 +6916,7 @@ Exija detalhes de despesas como recibos e descrições, defina limites e padrõe [CONST.SEARCH.DATE_PRESETS.NEVER]: 'Nunca', [CONST.SEARCH.DATE_PRESETS.LAST_MONTH]: 'Mês passado', [CONST.SEARCH.DATE_PRESETS.THIS_MONTH]: 'Este mês', + [CONST.SEARCH.DATE_PRESETS.YEAR_TO_DATE]: 'Ano até a data', [CONST.SEARCH.DATE_PRESETS.LAST_STATEMENT]: 'Último extrato', }, }, @@ -6902,8 +6956,10 @@ Exija detalhes de despesas como recibos e descrições, defina limites e padrõe groupBy: { [CONST.SEARCH.GROUP_BY.FROM]: 'De', [CONST.SEARCH.GROUP_BY.CARD]: 'Cartão', - [CONST.SEARCH.GROUP_BY.WITHDRAWAL_ID]: 'ID de saque', + [CONST.SEARCH.GROUP_BY.WITHDRAWAL_ID]: 'ID da retirada', [CONST.SEARCH.GROUP_BY.CATEGORY]: 'Categoria', + [CONST.SEARCH.GROUP_BY.TAG]: 'Etiqueta', + [CONST.SEARCH.GROUP_BY.MONTH]: 'Mês', }, feed: 'Feed', withdrawalType: { diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index 68624fc409d0..4bfcf7b5a0d4 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -17,6 +17,8 @@ import dedent from '@libs/StringUtils/dedent'; import CONST from '@src/CONST'; import type {Country} from '@src/CONST'; import type OriginalMessage from '@src/types/onyx/OriginalMessage'; +import {PolicyRulesModifiedFields} from '@src/types/onyx/OriginalMessage'; +import ObjectUtils from '@src/types/utils/ObjectUtils'; import type en from './en'; import type { ChangeFieldParams, @@ -634,6 +636,8 @@ const translations: TranslationDeepObject = { insights: '洞察', duplicateExpense: '重复报销', newFeature: '新功能', + month: '月', + home: '首页', }, supportalNoAccess: { title: '先别急', @@ -755,6 +759,16 @@ const translations: TranslationDeepObject = { enableQuickVerification: { biometrics: '使用您的脸部或指纹启用快速安全验证。无需密码或代码。', }, + revoke: { + revoke: '撤销', + title: '面部识别/指纹识别与通行密钥', + explanation: '在一台或多台设备上已启用面部 / 指纹或通行密钥验证。撤销访问权限后,下次在任何设备上进行验证时都需要使用魔法验证码', + confirmationPrompt: '你确定吗?在任何设备上进行下一步验证时,你都需要一个魔法代码', + cta: '撤销访问权限', + noDevices: '您尚未注册任何用于人脸/指纹或通行密钥验证的设备。如果您注册了设备,您将可以在此撤销其访问权限。', + dismiss: '明白了', + error: '请求失败。请稍后重试。', + }, }, validateCodeModal: { successfulSignInTitle: dedent(` @@ -905,6 +919,7 @@ const translations: TranslationDeepObject = { beginningOfChatHistorySelfDM: '这是你的个人空间。可用于记录笔记、任务、草稿和提醒。', beginningOfChatHistorySystemDM: '欢迎!我们来为你完成设置。', chatWithAccountManager: '在这里与您的客户经理聊天', + askMeAnything: '问我任何问题!', sayHello: '打个招呼!', yourSpace: '您的空间', welcomeToRoom: ({roomName}: WelcomeToRoomParams) => `欢迎来到 ${roomName}!`, @@ -1459,6 +1474,32 @@ const translations: TranslationDeepObject = { }, correctDistanceRateError: '修复里程费率错误后请重试。', AskToExplain: `. 解释 ✨`, + policyRulesModifiedFields: (policyRulesModifiedFields: PolicyRulesModifiedFields, policyRulesRoute: string, formatList: (list: string[]) => string) => { + const entries = ObjectUtils.typedEntries(policyRulesModifiedFields); + const fragments = entries.map(([key, value], i) => { + const isFirst = i === 0; + if (key === 'reimbursable') { + return value ? '将该报销单标记为“可报销”' : '将该报销单标记为“不可报销”'; + } + if (key === 'billable') { + return value ? '将该报销标记为“可向客户收费”' : '已将该报销标记为“不可计费”'; + } + if (key === 'tax') { + const taxEntry = value as PolicyRulesModifiedFields['tax']; + const taxRateName = taxEntry?.field_id_TAX.name ?? ''; + if (isFirst) { + return `将税率设置为“${taxRateName}”`; + } + return `税率为“${taxRateName}”`; + } + const updatedValue = value as string | boolean; + if (isFirst) { + return `将 ${translations.common[key].toLowerCase()} 设置为 “${updatedValue}”`; + } + return `${translations.common[key].toLowerCase()} 为 “${updatedValue}”`; + }); + return `${formatList(fragments)} 通过工作区规则`; + }, }, transactionMerge: { listPage: { @@ -6172,6 +6213,17 @@ ${reportName} ruleSummarySubtitleUpdateField: (fieldName: string, fieldValue: string) => `将 ${fieldName} 更新为“${fieldValue}”`, ruleSummarySubtitleReimbursable: (reimbursable: boolean) => `标记为“${reimbursable ? '可报销' : '不予报销'}”`, ruleSummarySubtitleBillable: (billable: boolean) => `标记为“${billable ? '可计费' : '不可计费'}”`, + addRuleTitle: '添加规则', + expensesWith: '对于以下费用:', + applyUpdates: '应用这些更新:', + merchantHint: '使用不区分大小写的“包含”匹配来匹配商户名称', + saveRule: '保存规则', + confirmError: '输入商家并至少应用一项更新', + confirmErrorMerchant: '请输入商家', + confirmErrorUpdate: '请至少进行一次更新', + editRuleTitle: '编辑规则', + deleteRule: '删除规则', + deleteRuleConfirmation: '您确定要删除此规则吗?', }, }, planTypePage: { @@ -6711,7 +6763,8 @@ ${reportName} [CONST.SEARCH.DATE_PRESETS.NEVER]: '从不', [CONST.SEARCH.DATE_PRESETS.LAST_MONTH]: '上个月', [CONST.SEARCH.DATE_PRESETS.THIS_MONTH]: '本月', - [CONST.SEARCH.DATE_PRESETS.LAST_STATEMENT]: '最新结单', + [CONST.SEARCH.DATE_PRESETS.YEAR_TO_DATE]: '本年截至目前', + [CONST.SEARCH.DATE_PRESETS.LAST_STATEMENT]: '最新对账单', }, }, status: '状态', @@ -6748,10 +6801,12 @@ ${reportName} reimbursable: '可报销', purchaseCurrency: '购买货币', groupBy: { - [CONST.SEARCH.GROUP_BY.FROM]: '从', + [CONST.SEARCH.GROUP_BY.FROM]: '来自', [CONST.SEARCH.GROUP_BY.CARD]: '卡片', - [CONST.SEARCH.GROUP_BY.WITHDRAWAL_ID]: '提款 ID', + [CONST.SEARCH.GROUP_BY.WITHDRAWAL_ID]: '提现 ID', [CONST.SEARCH.GROUP_BY.CATEGORY]: '类别', + [CONST.SEARCH.GROUP_BY.TAG]: '标签', + [CONST.SEARCH.GROUP_BY.MONTH]: '月', }, feed: '动态', withdrawalType: { diff --git a/src/libs/API/parameters/SetPolicyCodingRuleParams.ts b/src/libs/API/parameters/SetPolicyCodingRuleParams.ts new file mode 100644 index 000000000000..d0f8f5ac9c1a --- /dev/null +++ b/src/libs/API/parameters/SetPolicyCodingRuleParams.ts @@ -0,0 +1,15 @@ +type SetPolicyCodingRuleParams = { + /** The policy ID that the rule will be created or updated for */ + policyID: string; + + /** The existing ruleID, or an optimistic one to create the rule */ + ruleID: string; + + /** The JSON value of the merchant rule, stringified */ + value: string; + + /** Whether to update the transactions that match the rule */ + shouldUpdateMatchingTransactions: boolean; +}; + +export default SetPolicyCodingRuleParams; diff --git a/src/libs/API/parameters/index.ts b/src/libs/API/parameters/index.ts index 01c8110626bb..b409eb168ffe 100644 --- a/src/libs/API/parameters/index.ts +++ b/src/libs/API/parameters/index.ts @@ -467,6 +467,7 @@ export type {default as ToggleConsolidatedDomainBillingParams} from './ToggleCon export type {default as RemoveDomainAdminParams} from './RemoveDomainAdminParams'; export type {default as DeleteDomainParams} from './DeleteDomainParams'; export type {default as GetDuplicateTransactionDetailsParams} from './GetDuplicateTransactionDetailsParams'; +export type {default as SetPolicyCodingRuleParams} from './SetPolicyCodingRuleParams'; export type {default as RegisterAuthenticationKeyParams} from './RegisterAuthenticationKeyParams'; export type {default as TroubleshootMultifactorAuthenticationParams} from './TroubleshootMultifactorAuthenticationParams'; export type {default as RequestAuthenticationChallengeParams} from './RequestAuthenticationChallengeParams'; diff --git a/src/libs/API/types.ts b/src/libs/API/types.ts index 8ff474cef14f..c0682c87fede 100644 --- a/src/libs/API/types.ts +++ b/src/libs/API/types.ts @@ -254,6 +254,7 @@ const WRITE_COMMANDS = { ENABLE_POLICY_INVOICING: 'EnablePolicyInvoicing', ENABLE_POLICY_TIME_TRACKING: 'EnablePolicyTimeTracking', SET_POLICY_RULES_ENABLED: 'SetPolicyRulesEnabled', + SET_POLICY_CODING_RULE: 'SetPolicyCodingRule', SET_POLICY_EXPENSE_MAX_AMOUNT_NO_RECEIPT: 'SetPolicyExpenseMaxAmountNoReceipt', SET_POLICY_EXPENSE_MAX_AMOUNT_NO_ITEMIZED_RECEIPT: 'SetPolicyExpenseMaxAmountNoItemizedReceipt', SET_POLICY_EXPENSE_MAX_AMOUNT: 'SetPolicyExpenseMaxAmount', @@ -799,6 +800,7 @@ type WriteCommandParameters = { [WRITE_COMMANDS.ENABLE_POLICY_INVOICING]: Parameters.EnablePolicyInvoicingParams; [WRITE_COMMANDS.ENABLE_POLICY_TIME_TRACKING]: Parameters.EnablePolicyTimeTrackingParams; [WRITE_COMMANDS.SET_POLICY_RULES_ENABLED]: Parameters.SetPolicyRulesEnabledParams; + [WRITE_COMMANDS.SET_POLICY_CODING_RULE]: Parameters.SetPolicyCodingRuleParams; [WRITE_COMMANDS.SET_POLICY_REQUIRE_COMPANY_CARDS_ENABLED]: Parameters.SetPolicyRequireCompanyCardsEnabledParams; [WRITE_COMMANDS.SET_POLICY_CATEGORY_DESCRIPTION_REQUIRED]: Parameters.SetPolicyCategoryDescriptionRequiredParams; [WRITE_COMMANDS.SET_POLICY_CATEGORY_ATTENDEES_REQUIRED]: Parameters.SetPolicyCategoryAttendeesRequiredParams; @@ -837,6 +839,7 @@ type WriteCommandParameters = { [WRITE_COMMANDS.CLEAR_OUTSTANDING_BALANCE]: null; [WRITE_COMMANDS.CANCEL_BILLING_SUBSCRIPTION]: Parameters.CancelBillingSubscriptionParams; [WRITE_COMMANDS.SET_POLICY_RULES_ENABLED]: Parameters.SetPolicyRulesEnabledParams; + [WRITE_COMMANDS.SET_POLICY_CODING_RULE]: Parameters.SetPolicyCodingRuleParams; [WRITE_COMMANDS.SET_POLICY_EXPENSE_MAX_AMOUNT_NO_RECEIPT]: Parameters.SetPolicyExpenseMaxAmountNoReceipt; [WRITE_COMMANDS.SET_POLICY_EXPENSE_MAX_AMOUNT_NO_ITEMIZED_RECEIPT]: Parameters.SetPolicyExpenseMaxAmountNoItemizedReceipt; [WRITE_COMMANDS.SET_POLICY_EXPENSE_MAX_AMOUNT]: Parameters.SetPolicyExpenseMaxAmount; @@ -1307,6 +1310,7 @@ const SIDE_EFFECT_REQUEST_COMMANDS = { REGISTER_AUTHENTICATION_KEY: 'RegisterAuthenticationKey', TROUBLESHOOT_MULTIFACTOR_AUTHENTICATION: 'TroubleshootMultifactorAuthentication', REQUEST_AUTHENTICATION_CHALLENGE: 'RequestAuthenticationChallenge', + REVOKE_MULTIFACTOR_AUTHENTICATION_CREDENTIALS: 'RevokeMultifactorAuthenticationCredentials', } as const; type SideEffectRequestCommand = ValueOf; @@ -1339,6 +1343,7 @@ type SideEffectRequestCommandParameters = { [SIDE_EFFECT_REQUEST_COMMANDS.REGISTER_AUTHENTICATION_KEY]: Parameters.RegisterAuthenticationKeyParams; [SIDE_EFFECT_REQUEST_COMMANDS.TROUBLESHOOT_MULTIFACTOR_AUTHENTICATION]: Parameters.TroubleshootMultifactorAuthenticationParams; [SIDE_EFFECT_REQUEST_COMMANDS.REQUEST_AUTHENTICATION_CHALLENGE]: Parameters.RequestAuthenticationChallengeParams; + [SIDE_EFFECT_REQUEST_COMMANDS.REVOKE_MULTIFACTOR_AUTHENTICATION_CREDENTIALS]: EmptyObject; }; type ApiRequestCommandParameters = WriteCommandParameters & ReadCommandParameters & SideEffectRequestCommandParameters; diff --git a/src/libs/DateUtils.ts b/src/libs/DateUtils.ts index 9c0ed13eff57..8b5341ae2d4e 100644 --- a/src/libs/DateUtils.ts +++ b/src/libs/DateUtils.ts @@ -916,6 +916,34 @@ const formatInTimeZoneWithFallback: typeof formatInTimeZone = (date, timeZone, f } }; +/** + * Returns the start and end dates of a month in the format yyyy-MM-dd. + * @param year - Year (e.g., 2025) + * @param month - Month (1-12, where 1 is January) + */ +function getMonthDateRange(year: number, month: number): {start: string; end: string} { + return { + start: format(new Date(year, month - 1, 1), 'yyyy-MM-dd'), + end: format(new Date(year, month, 0), 'yyyy-MM-dd'), + }; +} + +/** + * Checks if a date string (yyyy-MM-dd or yyyy-MM-dd HH:mm:ss) falls within a specific month. + * Uses string comparison to avoid timezone issues. + * + * @param dateString - Date string in format yyyy-MM-dd or yyyy-MM-dd HH:mm:ss + * @param year - Year (e.g., 2025) + * @param month - Month (1-12, where 1 is January) + */ +function isDateStringInMonth(dateString: string, year: number, month: number): boolean { + const datePart = dateString.substring(0, 10); + const {start: monthStart, end: monthEnd} = getMonthDateRange(year, month); + + // String comparison works because yyyy-MM-dd format is lexicographically sortable + return datePart >= monthStart && datePart <= monthEnd; +} + const DateUtils = { isDate, formatToDayOfWeek, @@ -974,6 +1002,8 @@ const DateUtils = { getFormattedSplitDateRange, isCurrentTimeWithinRange, formatInTimeZoneWithFallback, + getMonthDateRange, + isDateStringInMonth, }; export default DateUtils; diff --git a/src/libs/E2E/tests/reportTypingTest.e2e.ts b/src/libs/E2E/tests/reportTypingTest.e2e.ts index b82192177e71..65c91309ff69 100644 --- a/src/libs/E2E/tests/reportTypingTest.e2e.ts +++ b/src/libs/E2E/tests/reportTypingTest.e2e.ts @@ -9,8 +9,8 @@ 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 {getRerenderCount, resetRerenderCount} from '@pages/home/report/ReportActionCompose/ComposerWithSuggestions/index.e2e'; -import {onSubmitAction} from '@pages/home/report/ReportActionCompose/ReportActionCompose'; +import {getRerenderCount, resetRerenderCount} from '@pages/inbox/report/ReportActionCompose/ComposerWithSuggestions/index.e2e'; +import {onSubmitAction} from '@pages/inbox/report/ReportActionCompose/ReportActionCompose'; import CONST from '@src/CONST'; import ROUTES from '@src/ROUTES'; import {makeBackspaceCommand, makeTypeTextCommand} from '../../../../tests/e2e/nativeCommands/NativeCommandsAction'; diff --git a/src/libs/ExpenseRuleUtils.ts b/src/libs/ExpenseRuleUtils.ts index 918d649c4c9d..ef106bb07dd9 100644 --- a/src/libs/ExpenseRuleUtils.ts +++ b/src/libs/ExpenseRuleUtils.ts @@ -54,7 +54,7 @@ function formatExpenseRuleChanges(rule: ExpenseRule, translate: LocaleContextPro if (rule.tag) { addChange('tag', getCleanedTagName(rule.tag)); } - if (rule.tax?.field_id_TAX) { + if (rule.tax?.field_id_TAX?.value) { addChange('tax', rule.tax.field_id_TAX.value); } if (rule.report) { diff --git a/src/libs/LoginUtils.ts b/src/libs/LoginUtils.ts index 6201df99d63f..d9de9141cb50 100644 --- a/src/libs/LoginUtils.ts +++ b/src/libs/LoginUtils.ts @@ -105,7 +105,7 @@ function handleSAMLLoginError(errorMessage: string, shouldClearSignInData: boole } setAccountError(errorMessage); - Navigation.goBack(ROUTES.HOME); + Navigation.goBack(ROUTES.INBOX); } function formatE164PhoneNumber(phoneNumber: string, countryCode: number) { diff --git a/src/libs/ModifiedExpenseMessage.ts b/src/libs/ModifiedExpenseMessage.ts index 4df78d4fa146..c23c05bbc2b4 100644 --- a/src/libs/ModifiedExpenseMessage.ts +++ b/src/libs/ModifiedExpenseMessage.ts @@ -12,7 +12,7 @@ import {convertToDisplayString} from './CurrencyUtils'; import DateUtils from './DateUtils'; import {getEnvironmentURL} from './Environment/Environment'; // eslint-disable-next-line @typescript-eslint/no-deprecated -import {translateLocal} from './Localize'; +import {formatList, translateLocal} from './Localize'; import Log from './Log'; import Parser from './Parser'; import {getPersonalDetailByEmail} from './PersonalDetailsUtils'; @@ -469,6 +469,18 @@ function getForReportAction({ buildMessageFragmentForValue(translateLocal, oldAttendees, attendees, translateLocal('iou.attendees'), false, setFragments, removalFragments, changeFragments); } + const hasPolicyRulesModifiedFields = isReportActionOriginalMessageAnObject && 'policyRulesModifiedFields' in reportActionOriginalMessage && 'policyID' in reportActionOriginalMessage; + if (hasPolicyRulesModifiedFields) { + const rulePolicyID = reportActionOriginalMessage.policyID; + const policyRulesModifiedFields = reportActionOriginalMessage.policyRulesModifiedFields; + + if (policyRulesModifiedFields && rulePolicyID) { + const policyRulesRoute = `${environmentURL}/${ROUTES.WORKSPACE_RULES.getRoute(rulePolicyID)}`; + // eslint-disable-next-line @typescript-eslint/no-deprecated + return translateLocal('iou.policyRulesModifiedFields', policyRulesModifiedFields, policyRulesRoute, formatList); + } + } + const message = // eslint-disable-next-line @typescript-eslint/no-deprecated getMessageLine(translateLocal, `\n${translateLocal('iou.changed')}`, changeFragments) + @@ -713,10 +725,21 @@ function getForReportActionTemp({ buildMessageFragmentForValue(translate, oldAttendees, attendees, translate('iou.attendees'), false, setFragments, removalFragments, changeFragments); } + const hasPolicyRulesModifiedFields = isReportActionOriginalMessageAnObject && 'policyRulesModifiedFields' in reportActionOriginalMessage && 'policyID' in reportActionOriginalMessage; + if (hasPolicyRulesModifiedFields) { + const {policyRulesModifiedFields, policyID} = reportActionOriginalMessage; + + if (policyRulesModifiedFields && policyID) { + const policyRulesRoute = `${environmentURL}/${ROUTES.WORKSPACE_RULES.getRoute(policyID)}`; + return translate('iou.policyRulesModifiedFields', policyRulesModifiedFields, policyRulesRoute, formatList); + } + } + const message = getMessageLine(translate, `\n${translate('iou.changed')}`, changeFragments) + getMessageLine(translate, `\n${translate('iou.set')}`, setFragments) + getMessageLine(translate, `\n${translate('iou.removed')}`, removalFragments); + if (message === '') { return translate('iou.changedTheExpense'); } diff --git a/src/libs/MultifactorAuthentication/Biometrics/VALUES.ts b/src/libs/MultifactorAuthentication/Biometrics/VALUES.ts index 3638cfbeeb07..5ce9716fd9cd 100644 --- a/src/libs/MultifactorAuthentication/Biometrics/VALUES.ts +++ b/src/libs/MultifactorAuthentication/Biometrics/VALUES.ts @@ -30,6 +30,7 @@ const REASON = { SIGNATURE_VERIFICATION_FAILED: 'Signature verification failed', NO_PENDING_REGISTRATION_CHALLENGE: 'No pending registration challenge', UNKNOWN_RESPONSE: 'Unknown response', + REVOKE_SUCCESSFUL: 'Revoked successfully', }, CHALLENGE: { COULD_NOT_RETRIEVE_A_CHALLENGE: 'Could not retrieve a challenge', @@ -112,6 +113,11 @@ const API_RESPONSE_MAP = { ...MULTIFACTOR_AUTHENTICATION_COMMAND_BASE_RESPONSE_MAP, 200: REASON.BACKEND.AUTHORIZATION_SUCCESSFUL, }, + + REVOKE_MULTIFACTOR_AUTHENTICATION_SETUP: { + ...MULTIFACTOR_AUTHENTICATION_COMMAND_BASE_RESPONSE_MAP, + 200: REASON.BACKEND.REVOKE_SUCCESSFUL, + }, } as const; /* eslint-enable @typescript-eslint/naming-convention */ diff --git a/src/libs/Navigation/AppNavigator/AuthScreens.tsx b/src/libs/Navigation/AppNavigator/AuthScreens.tsx index 19d6672ae2ee..1afe91bbd0a2 100644 --- a/src/libs/Navigation/AppNavigator/AuthScreens.tsx +++ b/src/libs/Navigation/AppNavigator/AuthScreens.tsx @@ -86,6 +86,7 @@ const loadLogOutPreviousUserPage = () => require('../../.. const loadConciergePage = () => require('../../../pages/ConciergePage').default; const loadTrackExpensePage = () => require('../../../pages/TrackExpensePage').default; const loadSubmitExpensePage = () => require('../../../pages/SubmitExpensePage').default; +const loadHomePage = () => require('../../../pages/HomePage').default; const loadWorkspaceJoinUser = () => require('@pages/workspace/WorkspaceJoinUserPage').default; const loadReportSplitNavigator = () => require('./Navigators/ReportsSplitNavigator').default; @@ -524,6 +525,7 @@ function AuthScreens() { NAVIGATORS.SEARCH_FULLSCREEN_NAVIGATOR, NAVIGATORS.RIGHT_MODAL_NAVIGATOR, SCREENS.WORKSPACES_LIST, + SCREENS.HOME, SCREENS.SEARCH.ROOT, ]} > @@ -533,6 +535,11 @@ function AuthScreens() { options={getFullscreenNavigatorOptions} getComponent={loadReportSplitNavigator} /> + ({ [SCREENS.REPORT_DETAILS.ROOT]: () => require('../../../../pages/ReportDetailsPage').default, - [SCREENS.REPORT_DETAILS.SHARE_CODE]: () => require('../../../../pages/home/report/ReportDetailsShareCodePage').default, - [SCREENS.REPORT_DETAILS.EXPORT]: () => require('../../../../pages/home/report/ReportDetailsExportPage').default, + [SCREENS.REPORT_DETAILS.SHARE_CODE]: () => require('../../../../pages/inbox/report/ReportDetailsShareCodePage').default, + [SCREENS.REPORT_DETAILS.EXPORT]: () => require('../../../../pages/inbox/report/ReportDetailsExportPage').default, }); const ReportCardActivateStackNavigator = createModalStackNavigator({ - [SCREENS.REPORT_CARD_ACTIVATE]: () => require('../../../../pages/home/report/ActivatePhysicalCardPage').default, + [SCREENS.REPORT_CARD_ACTIVATE]: () => require('../../../../pages/inbox/report/ActivatePhysicalCardPage').default, }); const ReportChangeWorkspaceModalStackNavigator = createModalStackNavigator({ @@ -262,8 +262,8 @@ const TaskModalStackNavigator = createModalStackNavigator({ - [SCREENS.REPORT_VERIFY_ACCOUNT]: () => require('../../../../pages/home/report/ReportVerifyAccountPage').default, - [SCREENS.EXPENSE_REPORT_VERIFY_ACCOUNT]: () => require('../../../../pages/home/report/ExpenseReportVerifyAccountPage').default, + [SCREENS.REPORT_VERIFY_ACCOUNT]: () => require('../../../../pages/inbox/report/ReportVerifyAccountPage').default, + [SCREENS.EXPENSE_REPORT_VERIFY_ACCOUNT]: () => require('../../../../pages/inbox/report/ExpenseReportVerifyAccountPage').default, [SCREENS.SEARCH.REPORT_VERIFY_ACCOUNT]: () => require('../../../../pages/Search/SearchReportVerifyAccountPage').default, }); @@ -822,6 +822,16 @@ const SettingsModalStackNavigator = createModalStackNavigator require('../../../../pages/workspace/rules/RulesReimbursableDefaultPage').default, [SCREENS.WORKSPACE.RULES_CUSTOM]: () => require('../../../../pages/workspace/rules/RulesCustomPage').default, [SCREENS.WORKSPACE.RULES_PROHIBITED_DEFAULT]: () => require('../../../../pages/workspace/rules/RulesProhibitedDefaultPage').default, + [SCREENS.WORKSPACE.RULES_MERCHANT_NEW]: () => require('../../../../pages/workspace/rules/MerchantRules/AddMerchantRulePage').default, + [SCREENS.WORKSPACE.RULES_MERCHANT_MERCHANT_TO_MATCH]: () => require('../../../../pages/workspace/rules/MerchantRules/AddMerchantToMatchPage').default, + [SCREENS.WORKSPACE.RULES_MERCHANT_MERCHANT]: () => require('../../../../pages/workspace/rules/MerchantRules/AddMerchantPage').default, + [SCREENS.WORKSPACE.RULES_MERCHANT_CATEGORY]: () => require('../../../../pages/workspace/rules/MerchantRules/AddCategoryPage').default, + [SCREENS.WORKSPACE.RULES_MERCHANT_TAG]: () => require('../../../../pages/workspace/rules/MerchantRules/AddTagPage').default, + [SCREENS.WORKSPACE.RULES_MERCHANT_TAX]: () => require('../../../../pages/workspace/rules/MerchantRules/AddTaxPage').default, + [SCREENS.WORKSPACE.RULES_MERCHANT_DESCRIPTION]: () => require('../../../../pages/workspace/rules/MerchantRules/AddDescriptionPage').default, + [SCREENS.WORKSPACE.RULES_MERCHANT_REIMBURSABLE]: () => require('../../../../pages/workspace/rules/MerchantRules/AddReimbursablePage').default, + [SCREENS.WORKSPACE.RULES_MERCHANT_BILLABLE]: () => require('../../../../pages/workspace/rules/MerchantRules/AddBillablePage').default, + [SCREENS.WORKSPACE.RULES_MERCHANT_EDIT]: () => require('../../../../pages/workspace/rules/MerchantRules/EditMerchantRulePage').default, [SCREENS.WORKSPACE.PER_DIEM_IMPORT]: () => require('../../../../pages/workspace/perDiem/ImportPerDiemPage').default, [SCREENS.WORKSPACE.PER_DIEM_IMPORTED]: () => require('../../../../pages/workspace/perDiem/ImportedPerDiemPage').default, [SCREENS.WORKSPACE.PER_DIEM_SETTINGS]: () => require('../../../../pages/workspace/perDiem/WorkspacePerDiemSettingsPage').default, @@ -1020,6 +1030,7 @@ const MultifactorAuthenticationStackNavigator = createModalStackNavigator require('../../../../pages/MultifactorAuthentication/BiometricsTestPage').default, [SCREENS.MULTIFACTOR_AUTHENTICATION.OUTCOME]: () => require('@pages/MultifactorAuthentication/OutcomePage').default, [SCREENS.MULTIFACTOR_AUTHENTICATION.PROMPT]: () => require('../../../../pages/MultifactorAuthentication/PromptPage').default, + [SCREENS.MULTIFACTOR_AUTHENTICATION.REVOKE]: () => require('@pages/MultifactorAuthentication/RevokePage').default, [SCREENS.MULTIFACTOR_AUTHENTICATION.NOT_FOUND]: () => require('../../../../pages/ErrorPage/NotFoundPage').default, }); diff --git a/src/libs/Navigation/AppNavigator/Navigators/ReportsSplitNavigator.tsx b/src/libs/Navigation/AppNavigator/Navigators/ReportsSplitNavigator.tsx index ad64157de00a..e3edeb9b0788 100644 --- a/src/libs/Navigation/AppNavigator/Navigators/ReportsSplitNavigator.tsx +++ b/src/libs/Navigation/AppNavigator/Navigators/ReportsSplitNavigator.tsx @@ -15,8 +15,8 @@ import ROUTES from '@src/ROUTES'; import SCREENS from '@src/SCREENS'; import type ReactComponentModule from '@src/types/utils/ReactComponentModule'; -const loadReportScreen = () => require('@pages/home/ReportScreen').default; -const loadSidebarScreen = () => require('@pages/home/sidebar/BaseSidebarScreen').default; +const loadReportScreen = () => require('@pages/inbox/ReportScreen').default; +const loadSidebarScreen = () => require('@pages/inbox/sidebar/BaseSidebarScreen').default; const Split = createSplitNavigator(); /** @@ -61,14 +61,14 @@ function ReportsSplitNavigator({route}: PlatformStackScreenProps diff --git a/src/libs/Navigation/AppNavigator/Navigators/RightModalNavigator.tsx b/src/libs/Navigation/AppNavigator/Navigators/RightModalNavigator.tsx index ab6222ef13c4..ef61a63947e2 100644 --- a/src/libs/Navigation/AppNavigator/Navigators/RightModalNavigator.tsx +++ b/src/libs/Navigation/AppNavigator/Navigators/RightModalNavigator.tsx @@ -83,7 +83,7 @@ function SecondaryOverlay() { return null; } -const loadRHPReportScreen = () => require('../../../../pages/home/RHPReportScreen').default; +const loadRHPReportScreen = () => require('../../../../pages/inbox/RHPReportScreen').default; const loadSearchMoneyRequestReportPage = () => require('../../../../pages/Search/SearchMoneyRequestReportPage').default; function RightModalNavigator({navigation, route}: RightModalNavigatorProps) { diff --git a/src/libs/Navigation/AppNavigator/useNavigationResetOnLayoutChange.ts b/src/libs/Navigation/AppNavigator/useNavigationResetOnLayoutChange.ts index c3e8e18fb7c8..82b96ce7d3d9 100644 --- a/src/libs/Navigation/AppNavigator/useNavigationResetOnLayoutChange.ts +++ b/src/libs/Navigation/AppNavigator/useNavigationResetOnLayoutChange.ts @@ -1,8 +1,12 @@ -import type {ParamListBase} from '@react-navigation/native'; +import type {ParamListBase, StackNavigationState} from '@react-navigation/native'; import {useEffect} from 'react'; +import usePrevious from '@hooks/usePrevious'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; +import isRoutePreloaded from '@libs/Navigation/helpers/isRoutePreloaded'; import navigationRef from '@libs/Navigation/navigationRef'; import type {CustomEffectsHookProps} from '@libs/Navigation/PlatformStackNavigation/types'; +import type {NavigationPartialRoute} from '@libs/Navigation/types'; +import NAVIGATORS from '@src/NAVIGATORS'; /** * This hook resets the navigation root state when changing the layout size, resetting the state calls the getRehydratedState method in CustomFullScreenRouter.tsx. @@ -12,11 +16,25 @@ import type {CustomEffectsHookProps} from '@libs/Navigation/PlatformStackNavigat */ function useNavigationResetOnLayoutChange({navigation}: CustomEffectsHookProps) { const {shouldUseNarrowLayout} = useResponsiveLayout(); + const previousShouldUseNarrowLayout = usePrevious(shouldUseNarrowLayout); + const hasLayoutBeenExpanded = previousShouldUseNarrowLayout && !shouldUseNarrowLayout; useEffect(() => { if (!navigationRef.isReady()) { return; } + + // If the ReportsSplitNavigator has been preloaded on a narrow layout, the Report page won't be displayed on a wide screen. + if (hasLayoutBeenExpanded && isRoutePreloaded(NAVIGATORS.REPORTS_SPLIT_NAVIGATOR)) { + const currentState = navigation.getState() as StackNavigationState & {preloadedRoutes?: NavigationPartialRoute[]}; + const stateWithoutPreloadedInbox = { + ...currentState, + preloadedRoutes: currentState.preloadedRoutes?.filter((route: NavigationPartialRoute) => route.name !== NAVIGATORS.REPORTS_SPLIT_NAVIGATOR), + }; + navigation.reset(stateWithoutPreloadedInbox); + return; + } + navigation.reset(navigation.getState()); // eslint-disable-next-line react-hooks/exhaustive-deps }, [shouldUseNarrowLayout]); diff --git a/src/libs/Navigation/AppNavigator/usePreloadFullScreenNavigators.ts b/src/libs/Navigation/AppNavigator/usePreloadFullScreenNavigators.ts index 49a9448d2f12..0fb4d2556ea6 100644 --- a/src/libs/Navigation/AppNavigator/usePreloadFullScreenNavigators.ts +++ b/src/libs/Navigation/AppNavigator/usePreloadFullScreenNavigators.ts @@ -25,7 +25,7 @@ import {getPreservedNavigatorState} from './createSplitNavigator/usePreserveNavi const TIMING_TO_CALL_PRELOAD = 1000; // Currently the Inbox, Workspaces and Account tabs are preloaded, while Search is not preloaded due to its potential complexity. -const TABS_TO_PRELOAD = [NAVIGATION_TABS.HOME, NAVIGATION_TABS.WORKSPACES, NAVIGATION_TABS.SETTINGS]; +const TABS_TO_PRELOAD = [NAVIGATION_TABS.INBOX, NAVIGATION_TABS.WORKSPACES, NAVIGATION_TABS.SETTINGS]; function preloadWorkspacesTab(navigation: PlatformStackNavigationProp) { const state = getWorkspacesTabStateFromSessionStorage() ?? navigation.getState(); @@ -61,7 +61,11 @@ function preloadAccountTab(navigation: PlatformStackNavigationProp) { - navigation.preload(NAVIGATORS.REPORTS_SPLIT_NAVIGATOR, {screen: SCREENS.HOME}); + navigation.preload(NAVIGATORS.REPORTS_SPLIT_NAVIGATOR, {screen: SCREENS.INBOX}); +} + +function preloadHomeTab(navigation: PlatformStackNavigationProp) { + navigation.preload(SCREENS.HOME); } function preloadTab(tabName: string, navigation: PlatformStackNavigationProp, subscriptionPlan: ValueOf | null) { @@ -75,9 +79,12 @@ function preloadTab(tabName: string, navigation: PlatformStackNavigationProp { cardStyleInterpolator: (props: StackCardInterpolationProps) => modalCardStyleInterpolator({props, isFullScreenModal: true}), }, }, - workspacesListPage: { + fullScreenTabPage: { ...commonScreenOptions, // We need to turn off animation for the full screen to avoid delay when closing screens. animation: Animations.NONE, diff --git a/src/libs/Navigation/Navigation.ts b/src/libs/Navigation/Navigation.ts index 632b3c49e0fa..47f38ca4df3c 100644 --- a/src/libs/Navigation/Navigation.ts +++ b/src/libs/Navigation/Navigation.ts @@ -505,7 +505,7 @@ function resetToHome() { name: SCREENS.REPORT, } : undefined; - const payload = getInitialSplitNavigatorState({name: SCREENS.HOME}, splitNavigatorMainScreen); + const payload = getInitialSplitNavigatorState({name: SCREENS.INBOX}, splitNavigatorMainScreen); navigationRef.dispatch({payload, type: CONST.NAVIGATION.ACTION_TYPE.REPLACE, target: rootState.key}); } @@ -519,7 +519,7 @@ function goBackToHome() { const isNarrowLayout = getIsNarrowLayout(); // This set the right split navigator. - goBack(ROUTES.HOME); + goBack(ROUTES.INBOX); // We want to keep the report screen in the split navigator on wide layout. if (!isNarrowLayout) { @@ -527,7 +527,7 @@ function goBackToHome() { } // This set the right route in this split navigator. - goBack(ROUTES.HOME); + goBack(ROUTES.INBOX); } /** diff --git a/src/libs/Navigation/helpers/getAdaptedStateFromPath.ts b/src/libs/Navigation/helpers/getAdaptedStateFromPath.ts index af421e67df2a..c75a5b6321c1 100644 --- a/src/libs/Navigation/helpers/getAdaptedStateFromPath.ts +++ b/src/libs/Navigation/helpers/getAdaptedStateFromPath.ts @@ -180,7 +180,7 @@ function getDefaultFullScreenRoute(route?: NavigationPartialRoute) { return getInitialSplitNavigatorState( { - name: SCREENS.HOME, + name: SCREENS.INBOX, }, { name: SCREENS.REPORT, diff --git a/src/libs/Navigation/helpers/isNavigatorName.ts b/src/libs/Navigation/helpers/isNavigatorName.ts index 4fdebfb9bdf7..959bef85c5b0 100644 --- a/src/libs/Navigation/helpers/isNavigatorName.ts +++ b/src/libs/Navigation/helpers/isNavigatorName.ts @@ -3,7 +3,7 @@ import type {FullScreenName, OnboardingFlowName, SplitNavigatorName, SplitNaviga import NAVIGATORS from '@src/NAVIGATORS'; import SCREENS from '@src/SCREENS'; -const FULL_SCREENS_SET = new Set([...Object.values(SIDEBAR_TO_SPLIT), NAVIGATORS.SEARCH_FULLSCREEN_NAVIGATOR, SCREENS.WORKSPACES_LIST]); +const FULL_SCREENS_SET = new Set([...Object.values(SIDEBAR_TO_SPLIT), NAVIGATORS.SEARCH_FULLSCREEN_NAVIGATOR, SCREENS.WORKSPACES_LIST, SCREENS.HOME]); const SIDEBARS_SET = new Set(Object.values(SPLIT_TO_SIDEBAR)); const ONBOARDING_SCREENS_SET = new Set(Object.values(SCREENS.ONBOARDING)); const SPLIT_NAVIGATORS_SET = new Set(Object.values(SIDEBAR_TO_SPLIT)); diff --git a/src/libs/Navigation/helpers/useIsHomeRouteActive.ts b/src/libs/Navigation/helpers/useIsHomeRouteActive.ts index 9c7c3fb63b5c..186f1c92fe8a 100644 --- a/src/libs/Navigation/helpers/useIsHomeRouteActive.ts +++ b/src/libs/Navigation/helpers/useIsHomeRouteActive.ts @@ -11,7 +11,7 @@ function useIsHomeRouteActive(isNarrowLayout: boolean) { const navigationState = useRootNavigationState((x) => x); if (isNarrowLayout) { - return focusedRoute?.name === SCREENS.HOME; + return focusedRoute?.name === SCREENS.INBOX; } // On full width screens HOME is always a sidebar to the Reports Screen diff --git a/src/libs/Navigation/linkingConfig/RELATIONS/SIDEBAR_TO_SPLIT.ts b/src/libs/Navigation/linkingConfig/RELATIONS/SIDEBAR_TO_SPLIT.ts index 47f9ada98281..5022e2a5da1f 100644 --- a/src/libs/Navigation/linkingConfig/RELATIONS/SIDEBAR_TO_SPLIT.ts +++ b/src/libs/Navigation/linkingConfig/RELATIONS/SIDEBAR_TO_SPLIT.ts @@ -4,7 +4,7 @@ import SCREENS from '@src/SCREENS'; // This file is used to define the relationship between the sidebar (LHN) and the parent split navigator. const SIDEBAR_TO_SPLIT = { [SCREENS.SETTINGS.ROOT]: NAVIGATORS.SETTINGS_SPLIT_NAVIGATOR, - [SCREENS.HOME]: NAVIGATORS.REPORTS_SPLIT_NAVIGATOR, + [SCREENS.INBOX]: NAVIGATORS.REPORTS_SPLIT_NAVIGATOR, [SCREENS.WORKSPACE.INITIAL]: NAVIGATORS.WORKSPACE_SPLIT_NAVIGATOR, [SCREENS.DOMAIN.INITIAL]: NAVIGATORS.DOMAIN_SPLIT_NAVIGATOR, }; diff --git a/src/libs/Navigation/linkingConfig/RELATIONS/TAB_TO_FULLSCREEN.ts b/src/libs/Navigation/linkingConfig/RELATIONS/TAB_TO_FULLSCREEN.ts index 3fd187d043e2..e41740a1ad0b 100644 --- a/src/libs/Navigation/linkingConfig/RELATIONS/TAB_TO_FULLSCREEN.ts +++ b/src/libs/Navigation/linkingConfig/RELATIONS/TAB_TO_FULLSCREEN.ts @@ -5,7 +5,8 @@ import NAVIGATORS from '@src/NAVIGATORS'; import SCREENS from '@src/SCREENS'; const TAB_TO_FULLSCREEN: Record, FullScreenName[]> = { - [NAVIGATION_TABS.HOME]: [NAVIGATORS.REPORTS_SPLIT_NAVIGATOR], + [NAVIGATION_TABS.HOME]: [SCREENS.HOME], + [NAVIGATION_TABS.INBOX]: [NAVIGATORS.REPORTS_SPLIT_NAVIGATOR], [NAVIGATION_TABS.SEARCH]: [NAVIGATORS.SEARCH_FULLSCREEN_NAVIGATOR], [NAVIGATION_TABS.SETTINGS]: [NAVIGATORS.SETTINGS_SPLIT_NAVIGATOR], [NAVIGATION_TABS.WORKSPACES]: [SCREENS.WORKSPACES_LIST, NAVIGATORS.WORKSPACE_SPLIT_NAVIGATOR, NAVIGATORS.DOMAIN_SPLIT_NAVIGATOR], diff --git a/src/libs/Navigation/linkingConfig/RELATIONS/WORKSPACE_TO_RHP.ts b/src/libs/Navigation/linkingConfig/RELATIONS/WORKSPACE_TO_RHP.ts index d6d510acce16..3f211f62349b 100755 --- a/src/libs/Navigation/linkingConfig/RELATIONS/WORKSPACE_TO_RHP.ts +++ b/src/libs/Navigation/linkingConfig/RELATIONS/WORKSPACE_TO_RHP.ts @@ -281,6 +281,16 @@ const WORKSPACE_TO_RHP: Partial['config'] = { [SCREENS.CONCIERGE]: ROUTES.CONCIERGE, [SCREENS.TRACK_EXPENSE]: ROUTES.TRACK_EXPENSE, [SCREENS.SUBMIT_EXPENSE]: ROUTES.SUBMIT_EXPENSE, + [SCREENS.HOME]: ROUTES.HOME, [SCREENS.SAML_SIGN_IN]: ROUTES.SAML_SIGN_IN, [SCREENS.REPORT_ATTACHMENTS]: ROUTES.REPORT_ATTACHMENTS.route, [SCREENS.REPORT_ADD_ATTACHMENT]: ROUTES.REPORT_ADD_ATTACHMENT.route, @@ -1152,6 +1153,36 @@ const config: LinkingOptions['config'] = { [SCREENS.WORKSPACE.RULES_PROHIBITED_DEFAULT]: { path: ROUTES.RULES_PROHIBITED_DEFAULT.route, }, + [SCREENS.WORKSPACE.RULES_MERCHANT_NEW]: { + path: ROUTES.RULES_MERCHANT_NEW.route, + }, + [SCREENS.WORKSPACE.RULES_MERCHANT_MERCHANT_TO_MATCH]: { + path: ROUTES.RULES_MERCHANT_MERCHANT_TO_MATCH.route, + }, + [SCREENS.WORKSPACE.RULES_MERCHANT_MERCHANT]: { + path: ROUTES.RULES_MERCHANT_MERCHANT.route, + }, + [SCREENS.WORKSPACE.RULES_MERCHANT_CATEGORY]: { + path: ROUTES.RULES_MERCHANT_CATEGORY.route, + }, + [SCREENS.WORKSPACE.RULES_MERCHANT_TAG]: { + path: ROUTES.RULES_MERCHANT_TAG.route, + }, + [SCREENS.WORKSPACE.RULES_MERCHANT_TAX]: { + path: ROUTES.RULES_MERCHANT_TAX.route, + }, + [SCREENS.WORKSPACE.RULES_MERCHANT_DESCRIPTION]: { + path: ROUTES.RULES_MERCHANT_DESCRIPTION.route, + }, + [SCREENS.WORKSPACE.RULES_MERCHANT_REIMBURSABLE]: { + path: ROUTES.RULES_MERCHANT_REIMBURSABLE.route, + }, + [SCREENS.WORKSPACE.RULES_MERCHANT_BILLABLE]: { + path: ROUTES.RULES_MERCHANT_BILLABLE.route, + }, + [SCREENS.WORKSPACE.RULES_MERCHANT_EDIT]: { + path: ROUTES.RULES_MERCHANT_EDIT.route, + }, [SCREENS.WORKSPACE.PER_DIEM_IMPORT]: { path: ROUTES.WORKSPACE_PER_DIEM_IMPORT.route, }, @@ -1953,6 +1984,7 @@ const config: LinkingOptions['config'] = { [SCREENS.MULTIFACTOR_AUTHENTICATION.OUTCOME]: ROUTES.MULTIFACTOR_AUTHENTICATION_OUTCOME.route, [SCREENS.MULTIFACTOR_AUTHENTICATION.PROMPT]: ROUTES.MULTIFACTOR_AUTHENTICATION_PROMPT.route, [SCREENS.MULTIFACTOR_AUTHENTICATION.NOT_FOUND]: ROUTES.MULTIFACTOR_AUTHENTICATION_NOT_FOUND, + [SCREENS.MULTIFACTOR_AUTHENTICATION.REVOKE]: ROUTES.MULTIFACTOR_AUTHENTICATION_REVOKE, }, }, }, @@ -1961,8 +1993,8 @@ const config: LinkingOptions['config'] = { [NAVIGATORS.REPORTS_SPLIT_NAVIGATOR]: { path: ROUTES.ROOT, screens: { - [SCREENS.HOME]: { - path: ROUTES.HOME, + [SCREENS.INBOX]: { + path: ROUTES.INBOX, exact: true, }, [SCREENS.REPORT]: { diff --git a/src/libs/Navigation/types.ts b/src/libs/Navigation/types.ts index 1cee192f433b..e4c462fb8d2a 100644 --- a/src/libs/Navigation/types.ts +++ b/src/libs/Navigation/types.ts @@ -1394,6 +1394,45 @@ type SettingsNavigatorParamList = { [SCREENS.WORKSPACE.RULES_CUSTOM]: { policyID: string; }; + [SCREENS.WORKSPACE.RULES_MERCHANT_NEW]: { + policyID: string; + }; + [SCREENS.WORKSPACE.RULES_MERCHANT_MERCHANT_TO_MATCH]: { + policyID: string; + ruleID: string; + }; + [SCREENS.WORKSPACE.RULES_MERCHANT_MERCHANT]: { + policyID: string; + ruleID: string; + }; + [SCREENS.WORKSPACE.RULES_MERCHANT_CATEGORY]: { + policyID: string; + ruleID: string; + }; + [SCREENS.WORKSPACE.RULES_MERCHANT_TAG]: { + policyID: string; + ruleID: string; + }; + [SCREENS.WORKSPACE.RULES_MERCHANT_TAX]: { + policyID: string; + ruleID: string; + }; + [SCREENS.WORKSPACE.RULES_MERCHANT_DESCRIPTION]: { + policyID: string; + ruleID: string; + }; + [SCREENS.WORKSPACE.RULES_MERCHANT_REIMBURSABLE]: { + policyID: string; + ruleID: string; + }; + [SCREENS.WORKSPACE.RULES_MERCHANT_BILLABLE]: { + policyID: string; + ruleID: string; + }; + [SCREENS.WORKSPACE.RULES_MERCHANT_EDIT]: { + policyID: string; + ruleID: string; + }; [SCREENS.WORKSPACE.PER_DIEM_IMPORT]: { policyID: string; }; @@ -2413,7 +2452,7 @@ type TravelNavigatorParamList = { }; type ReportsSplitNavigatorParamList = { - [SCREENS.HOME]: undefined; + [SCREENS.INBOX]: undefined; [SCREENS.REPORT]: { reportID: string; reportActionID?: string; @@ -2812,6 +2851,7 @@ type AuthScreensParamList = SharedScreensParamList & [SCREENS.CONCIERGE]: undefined; [SCREENS.TRACK_EXPENSE]: undefined; [SCREENS.SUBMIT_EXPENSE]: undefined; + [SCREENS.HOME]: undefined; [SCREENS.WORKSPACES_LIST]: { // eslint-disable-next-line no-restricted-syntax -- `backTo` usages in this file are legacy. Do not add new `backTo` params to screens. See contributingGuides/NAVIGATION.md backTo?: Routes; @@ -3021,7 +3061,7 @@ type SplitNavigatorName = keyof SplitNavigatorParamList; type SearchFullscreenNavigatorName = typeof NAVIGATORS.SEARCH_FULLSCREEN_NAVIGATOR; -type FullScreenName = SplitNavigatorName | SearchFullscreenNavigatorName | typeof SCREENS.WORKSPACES_LIST; +type FullScreenName = SplitNavigatorName | SearchFullscreenNavigatorName | typeof SCREENS.WORKSPACES_LIST | typeof SCREENS.HOME; // There are three screens/navigators which can be displayed when the Workspaces tab is selected type WorkspacesTabNavigatorName = typeof SCREENS.WORKSPACES_LIST | typeof NAVIGATORS.WORKSPACE_SPLIT_NAVIGATOR | typeof NAVIGATORS.DOMAIN_SPLIT_NAVIGATOR; diff --git a/src/libs/ReportActionsFollowupUtils.ts b/src/libs/ReportActionsFollowupUtils.ts new file mode 100644 index 000000000000..e068a3935dae --- /dev/null +++ b/src/libs/ReportActionsFollowupUtils.ts @@ -0,0 +1,57 @@ +import CONST from '@src/CONST'; +import type {OnyxInputOrEntry, ReportAction} from '@src/types/onyx'; +import type {Followup} from './ReportActionsUtils'; +import {getReportActionMessage, isActionOfType} from './ReportActionsUtils'; + +/** + * Checks if a report action contains actionable (unresolved) followup suggestions. + * @param reportAction - The report action to check + * @returns true if the action is an ADD_COMMENT with unresolved followups, false otherwise + */ +function containsActionableFollowUps(reportAction: OnyxInputOrEntry): boolean { + const isActionAComment = isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT); + if (!isActionAComment) { + return false; + } + const messageHtml = getReportActionMessage(reportAction)?.html; + if (!messageHtml) { + return false; + } + const followups = parseFollowupsFromHtml(messageHtml); + + return !!followups && followups.length > 0; +} + +// Matches a HTML element and its entire contents. (Question?) +const followUpListRegex = /]*)?>[\s\S]*?<\/followup-list>/i; +/** + * Parses followup data from a HTML element. + * @param html - The HTML string to parse for elements + * @returns null if no exists, empty array [] if the followup-list has the 'selected' attribute (resolved state), or an array of followup objects if unresolved + */ +function parseFollowupsFromHtml(html: string): Followup[] | null { + const followupListMatch = html.match(followUpListRegex); + if (!followupListMatch) { + return null; + } + + // There will be only one follow up list + const followupListHtml = followupListMatch[0]; + // Matches a element that has the "selected" attribute (...). + const followUpSelectedListRegex = /]*\sselected[\s>]/i; + const hasSelectedAttribute = followUpSelectedListRegex.test(followupListHtml); + if (hasSelectedAttribute) { + return []; + } + + const followups: Followup[] = []; + // Matches individual ... elements + const followUpTextRegex = /([^<]*)<\/followup-text><\/followup>/gi; + let match = followUpTextRegex.exec(followupListHtml); + while (match !== null) { + followups.push({text: match[1]}); + match = followUpTextRegex.exec(followupListHtml); + } + return followups; +} +export {containsActionableFollowUps, parseFollowupsFromHtml}; diff --git a/src/libs/ReportActionsUtils.ts b/src/libs/ReportActionsUtils.ts index fb89ac5bcab4..f6814a8a7c9a 100644 --- a/src/libs/ReportActionsUtils.ts +++ b/src/libs/ReportActionsUtils.ts @@ -64,6 +64,10 @@ type MemberChangeMessageRoomReferenceElement = { type MemberChangeMessageElement = MessageTextElement | MemberChangeMessageUserMentionElement | MemberChangeMessageRoomReferenceElement; +type Followup = { + text: string; +}; + function isPolicyExpenseChat(report: OnyxInputOrEntry): boolean { return report?.chatType === CONST.REPORT.CHAT_TYPE.POLICY_EXPENSE_CHAT || !!(report && typeof report === 'object' && 'isPolicyExpenseChat' in report && report.isPolicyExpenseChat); } @@ -1729,6 +1733,21 @@ function getMemberChangeMessageElements( ]; } +/** + * Used for generating preview text in LHN and other places where followups should not be displayed. + * Implemented here instead of ReportActionFollowupUtils due to circular ref + * @param html message.html from the report COMMENT actions + * @returns html with the element and its contents stripped out or undefined if html is undefined + */ +function stripFollowupListFromHtml(html?: string): string | undefined { + if (!html) { + return; + } + // Matches a HTML element and its entire contents. (Question?) + const followUpListRegex = /]*)?>[\s\S]*?<\/followup-list>/i; + return html.replace(followUpListRegex, '').trim(); +} + function getReportActionHtml(reportAction: PartialReportAction): string { return getReportActionMessage(reportAction)?.html ?? ''; } @@ -1737,7 +1756,7 @@ function getReportActionText(reportAction: PartialReportAction): string { const message = getReportActionMessage(reportAction); // Sometime html can be an empty string // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing - const text = (message?.html || message?.text) ?? ''; + const text = stripFollowupListFromHtml(message?.html) || (message?.text ?? ''); return text ? Parser.htmlToText(text) : ''; } @@ -3972,6 +3991,7 @@ export { withDEWRoutedActionsArray, withDEWRoutedActionsObject, getReportActionActorAccountID, + stripFollowupListFromHtml, }; -export type {LastVisibleMessage}; +export type {LastVisibleMessage, Followup}; diff --git a/src/libs/SearchParser/autocompleteParser.js b/src/libs/SearchParser/autocompleteParser.js index c9e5bd870353..95b3aea65ccd 100644 --- a/src/libs/SearchParser/autocompleteParser.js +++ b/src/libs/SearchParser/autocompleteParser.js @@ -270,14 +270,15 @@ function peg$parse(input, options) { var peg$c84 = "group-withdrawn"; var peg$c85 = "group-withdrawal-id"; var peg$c86 = "group-category"; - var peg$c87 = "!="; - var peg$c88 = ">="; - var peg$c89 = ">"; - var peg$c90 = "<="; - var peg$c91 = "<"; - var peg$c92 = "\u201C"; - var peg$c93 = "\u201D"; - var peg$c94 = "\""; + var peg$c87 = "group-month"; + var peg$c88 = "!="; + var peg$c89 = ">="; + var peg$c90 = ">"; + var peg$c91 = "<="; + var peg$c92 = "<"; + var peg$c93 = "\u201C"; + var peg$c94 = "\u201D"; + var peg$c95 = "\""; var peg$r0 = /^[ \t\r\n\xA0,:=<>!]/; var peg$r1 = /^[:=]/; @@ -383,30 +384,31 @@ function peg$parse(input, options) { var peg$e87 = peg$literalExpectation("group-withdrawn", true); var peg$e88 = peg$literalExpectation("group-withdrawal-id", true); var peg$e89 = peg$literalExpectation("group-category", true); - var peg$e90 = peg$otherExpectation("operator"); - var peg$e91 = peg$classExpectation([":", "="], false, false); - var peg$e92 = peg$literalExpectation("!=", false); - var peg$e93 = peg$literalExpectation(">=", false); - var peg$e94 = peg$literalExpectation(">", false); - var peg$e95 = peg$literalExpectation("<=", false); - var peg$e96 = peg$literalExpectation("<", false); - var peg$e97 = peg$otherExpectation("word"); - var peg$e98 = peg$classExpectation([" ", ",", "\t", "\n", "\r", "\xA0"], true, false); - var peg$e99 = peg$otherExpectation("whitespace"); - var peg$e100 = peg$classExpectation([" ", "\t", "\r", "\n", "\xA0"], false, false); - var peg$e101 = peg$otherExpectation("quote"); - var peg$e102 = peg$classExpectation([" ", ",", "\"", "\u201D", "\u201C", "\t", "\n", "\r", "\xA0"], true, false); - var peg$e103 = peg$classExpectation(["\"", ["\u201C", "\u201D"]], false, false); - var peg$e104 = peg$classExpectation(["\"", "\u201D", "\u201C", "\r", "\n"], true, false); - var peg$e105 = peg$literalExpectation("\u201C", false); - var peg$e106 = peg$literalExpectation("\u201D", false); - var peg$e107 = peg$literalExpectation("\"", false); - var peg$e108 = peg$classExpectation([" ", "\t", "\n", "\r", "\xA0", ["a", "z"], ["A", "Z"], ["0", "9"]], false, false); - var peg$e109 = peg$classExpectation([["a", "z"], ["A", "Z"], ["0", "9"]], false, false); - var peg$e110 = peg$classExpectation([" ", "\t", "\n", "\r", "\xA0"], false, false); - var peg$e111 = peg$classExpectation([" ", "\t", "\n", "\r", "\xA0", ["a", "z"], ["A", "Z"]], false, false); - var peg$e112 = peg$classExpectation([","], false, false); - var peg$e113 = peg$classExpectation([" ", "\t", "\n", "\r", "\xA0", ","], false, false); + var peg$e90 = peg$literalExpectation("group-month", true); + var peg$e91 = peg$otherExpectation("operator"); + var peg$e92 = peg$classExpectation([":", "="], false, false); + var peg$e93 = peg$literalExpectation("!=", false); + var peg$e94 = peg$literalExpectation(">=", false); + var peg$e95 = peg$literalExpectation(">", false); + var peg$e96 = peg$literalExpectation("<=", false); + var peg$e97 = peg$literalExpectation("<", false); + var peg$e98 = peg$otherExpectation("word"); + var peg$e99 = peg$classExpectation([" ", ",", "\t", "\n", "\r", "\xA0"], true, false); + var peg$e100 = peg$otherExpectation("whitespace"); + var peg$e101 = peg$classExpectation([" ", "\t", "\r", "\n", "\xA0"], false, false); + var peg$e102 = peg$otherExpectation("quote"); + var peg$e103 = peg$classExpectation([" ", ",", "\"", "\u201D", "\u201C", "\t", "\n", "\r", "\xA0"], true, false); + var peg$e104 = peg$classExpectation(["\"", ["\u201C", "\u201D"]], false, false); + var peg$e105 = peg$classExpectation(["\"", "\u201D", "\u201C", "\r", "\n"], true, false); + var peg$e106 = peg$literalExpectation("\u201C", false); + var peg$e107 = peg$literalExpectation("\u201D", false); + var peg$e108 = peg$literalExpectation("\"", false); + var peg$e109 = peg$classExpectation([" ", "\t", "\n", "\r", "\xA0", ["a", "z"], ["A", "Z"], ["0", "9"]], false, false); + var peg$e110 = peg$classExpectation([["a", "z"], ["A", "Z"], ["0", "9"]], false, false); + var peg$e111 = peg$classExpectation([" ", "\t", "\n", "\r", "\xA0"], false, false); + var peg$e112 = peg$classExpectation([" ", "\t", "\n", "\r", "\xA0", ["a", "z"], ["A", "Z"]], false, false); + var peg$e113 = peg$classExpectation([","], false, false); + var peg$e114 = peg$classExpectation([" ", "\t", "\n", "\r", "\xA0", ","], false, false); var peg$f0 = function(ranges) { return { autocomplete, ranges }; }; var peg$f1 = function(filters) { return filters.filter(Boolean).flat(); }; @@ -545,32 +547,33 @@ function peg$parse(input, options) { var peg$f76 = function() { return "groupWithdrawn"; }; var peg$f77 = function() { return "groupWithdrawalID"; }; var peg$f78 = function() { return "groupCategory"; }; - var peg$f79 = function() { return "eq"; }; - var peg$f80 = function() { return "neq"; }; - var peg$f81 = function() { return "gte"; }; - var peg$f82 = function() { return "gt"; }; - var peg$f83 = function() { return "lte"; }; - var peg$f84 = function() { return "lt"; }; - var peg$f85 = function(o) { + var peg$f79 = function() { return "groupMonth"; }; + var peg$f80 = function() { return "eq"; }; + var peg$f81 = function() { return "neq"; }; + var peg$f82 = function() { return "gte"; }; + var peg$f83 = function() { return "gt"; }; + var peg$f84 = function() { return "lte"; }; + var peg$f85 = function() { return "lt"; }; + var peg$f86 = function(o) { if (nameOperator) { expectingNestedQuote = (o === "eq"); // Use simple parser if no valid operator is found } isColumnsContext = false; return o; }; - var peg$f86 = function(chars) { return chars.join("").trim(); }; - var peg$f87 = function() { + var peg$f87 = function(chars) { return chars.join("").trim(); }; + var peg$f88 = function() { isColumnsContext = false; return "and"; }; - var peg$f88 = function() { return expectingNestedQuote; }; - var peg$f89 = function(start, inner, end) { //handle no-breaking space + var peg$f89 = function() { return expectingNestedQuote; }; + var peg$f90 = function(start, inner, end) { //handle no-breaking space return [...start, '"', ...inner, '"', ...end].join(""); }; - var peg$f90 = function(start) {return "“"}; - var peg$f91 = function(start) {return "”"}; - var peg$f92 = function(start) {return "\""}; - var peg$f93 = function(start, inner, end) { + var peg$f91 = function(start) {return "“"}; + var peg$f92 = function(start) {return "”"}; + var peg$f93 = function(start) {return "\""}; + var peg$f94 = function(start, inner, end) { return [...start, '"', ...inner, '"'].join(""); }; var peg$currPos = options.peg$currPos | 0; @@ -2371,6 +2374,9 @@ function peg$parse(input, options) { s0 = peg$parsegroupWithdrawalId(); if (s0 === peg$FAILED) { s0 = peg$parsegroupCategory(); + if (s0 === peg$FAILED) { + s0 = peg$parsegroupMonth(); + } } } } @@ -3255,6 +3261,43 @@ function peg$parse(input, options) { return s0; } + function peg$parsegroupMonth() { + var s0, s1, s2, s3; + + s0 = peg$currPos; + s1 = input.substr(peg$currPos, 11); + if (s1.toLowerCase() === peg$c87) { + peg$currPos += 11; + } else { + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e90); } + } + if (s1 !== peg$FAILED) { + s2 = peg$currPos; + peg$silentFails++; + s3 = peg$parsewordBoundary(); + peg$silentFails--; + if (s3 !== peg$FAILED) { + peg$currPos = s2; + s2 = undefined; + } else { + s2 = peg$FAILED; + } + if (s2 !== peg$FAILED) { + peg$savedPos = s0; + s0 = peg$f79(); + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + + return s0; + } + function peg$parseoperator() { var s0, s1; @@ -3265,81 +3308,81 @@ function peg$parse(input, options) { peg$currPos++; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e91); } + if (peg$silentFails === 0) { peg$fail(peg$e92); } } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f79(); + s1 = peg$f80(); } s0 = s1; if (s0 === peg$FAILED) { s0 = peg$currPos; - if (input.substr(peg$currPos, 2) === peg$c87) { - s1 = peg$c87; + if (input.substr(peg$currPos, 2) === peg$c88) { + s1 = peg$c88; peg$currPos += 2; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e92); } + if (peg$silentFails === 0) { peg$fail(peg$e93); } } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f80(); + s1 = peg$f81(); } s0 = s1; if (s0 === peg$FAILED) { s0 = peg$currPos; - if (input.substr(peg$currPos, 2) === peg$c88) { - s1 = peg$c88; + if (input.substr(peg$currPos, 2) === peg$c89) { + s1 = peg$c89; peg$currPos += 2; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e93); } + if (peg$silentFails === 0) { peg$fail(peg$e94); } } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f81(); + s1 = peg$f82(); } s0 = s1; if (s0 === peg$FAILED) { s0 = peg$currPos; if (input.charCodeAt(peg$currPos) === 62) { - s1 = peg$c89; + s1 = peg$c90; peg$currPos++; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e94); } + if (peg$silentFails === 0) { peg$fail(peg$e95); } } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f82(); + s1 = peg$f83(); } s0 = s1; if (s0 === peg$FAILED) { s0 = peg$currPos; - if (input.substr(peg$currPos, 2) === peg$c90) { - s1 = peg$c90; + if (input.substr(peg$currPos, 2) === peg$c91) { + s1 = peg$c91; peg$currPos += 2; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e95); } + if (peg$silentFails === 0) { peg$fail(peg$e96); } } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f83(); + s1 = peg$f84(); } s0 = s1; if (s0 === peg$FAILED) { s0 = peg$currPos; if (input.charCodeAt(peg$currPos) === 60) { - s1 = peg$c91; + s1 = peg$c92; peg$currPos++; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e96); } + if (peg$silentFails === 0) { peg$fail(peg$e97); } } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f84(); + s1 = peg$f85(); } s0 = s1; } @@ -3350,7 +3393,7 @@ function peg$parse(input, options) { peg$silentFails--; if (s0 === peg$FAILED) { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e90); } + if (peg$silentFails === 0) { peg$fail(peg$e91); } } return s0; @@ -3363,7 +3406,7 @@ function peg$parse(input, options) { s1 = peg$parseoperator(); if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f85(s1); + s1 = peg$f86(s1); } s0 = s1; @@ -3381,7 +3424,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s2 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e98); } + if (peg$silentFails === 0) { peg$fail(peg$e99); } } if (s2 !== peg$FAILED) { while (s2 !== peg$FAILED) { @@ -3391,7 +3434,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s2 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e98); } + if (peg$silentFails === 0) { peg$fail(peg$e99); } } } } else { @@ -3399,13 +3442,13 @@ function peg$parse(input, options) { } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f86(s1); + s1 = peg$f87(s1); } s0 = s1; peg$silentFails--; if (s0 === peg$FAILED) { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e97); } + if (peg$silentFails === 0) { peg$fail(peg$e98); } } return s0; @@ -3417,7 +3460,7 @@ function peg$parse(input, options) { s0 = peg$currPos; s1 = peg$parse_(); peg$savedPos = s0; - s1 = peg$f87(); + s1 = peg$f88(); s0 = s1; return s0; @@ -3433,7 +3476,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e100); } + if (peg$silentFails === 0) { peg$fail(peg$e101); } } while (s1 !== peg$FAILED) { s0.push(s1); @@ -3442,12 +3485,12 @@ function peg$parse(input, options) { peg$currPos++; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e100); } + if (peg$silentFails === 0) { peg$fail(peg$e101); } } } peg$silentFails--; s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e99); } + if (peg$silentFails === 0) { peg$fail(peg$e100); } return s0; } @@ -3457,7 +3500,7 @@ function peg$parse(input, options) { s0 = peg$currPos; peg$savedPos = peg$currPos; - s1 = peg$f88(); + s1 = peg$f89(); if (s1) { s1 = undefined; } else { @@ -3494,7 +3537,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s2 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e102); } + if (peg$silentFails === 0) { peg$fail(peg$e103); } } while (s2 !== peg$FAILED) { s1.push(s2); @@ -3503,7 +3546,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s2 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e102); } + if (peg$silentFails === 0) { peg$fail(peg$e103); } } } s2 = input.charAt(peg$currPos); @@ -3511,7 +3554,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s2 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e103); } + if (peg$silentFails === 0) { peg$fail(peg$e104); } } if (s2 !== peg$FAILED) { s3 = []; @@ -3520,7 +3563,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s4 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e104); } + if (peg$silentFails === 0) { peg$fail(peg$e105); } } while (s4 !== peg$FAILED) { s3.push(s4); @@ -3529,7 +3572,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s4 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e104); } + if (peg$silentFails === 0) { peg$fail(peg$e105); } } } s4 = input.charAt(peg$currPos); @@ -3537,7 +3580,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s4 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e103); } + if (peg$silentFails === 0) { peg$fail(peg$e104); } } if (s4 !== peg$FAILED) { s5 = []; @@ -3546,7 +3589,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s6 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e98); } + if (peg$silentFails === 0) { peg$fail(peg$e99); } } while (s6 !== peg$FAILED) { s5.push(s6); @@ -3555,11 +3598,11 @@ function peg$parse(input, options) { peg$currPos++; } else { s6 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e98); } + if (peg$silentFails === 0) { peg$fail(peg$e99); } } } peg$savedPos = s0; - s0 = peg$f89(s1, s3, s5); + s0 = peg$f90(s1, s3, s5); } else { peg$currPos = s0; s0 = peg$FAILED; @@ -3571,7 +3614,7 @@ function peg$parse(input, options) { peg$silentFails--; if (s0 === peg$FAILED) { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e101); } + if (peg$silentFails === 0) { peg$fail(peg$e102); } } return s0; @@ -3588,7 +3631,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s2 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e102); } + if (peg$silentFails === 0) { peg$fail(peg$e103); } } while (s2 !== peg$FAILED) { s1.push(s2); @@ -3597,7 +3640,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s2 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e102); } + if (peg$silentFails === 0) { peg$fail(peg$e103); } } } s2 = input.charAt(peg$currPos); @@ -3605,7 +3648,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s2 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e103); } + if (peg$silentFails === 0) { peg$fail(peg$e104); } } if (s2 !== peg$FAILED) { s3 = []; @@ -3614,7 +3657,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s4 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e104); } + if (peg$silentFails === 0) { peg$fail(peg$e105); } } if (s4 === peg$FAILED) { s4 = peg$currPos; @@ -3630,15 +3673,15 @@ function peg$parse(input, options) { } if (s5 !== peg$FAILED) { if (input.charCodeAt(peg$currPos) === 8220) { - s6 = peg$c92; + s6 = peg$c93; peg$currPos++; } else { s6 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e105); } + if (peg$silentFails === 0) { peg$fail(peg$e106); } } if (s6 !== peg$FAILED) { peg$savedPos = s4; - s4 = peg$f90(s1); + s4 = peg$f91(s1); } else { peg$currPos = s4; s4 = peg$FAILED; @@ -3661,15 +3704,15 @@ function peg$parse(input, options) { } if (s5 !== peg$FAILED) { if (input.charCodeAt(peg$currPos) === 8221) { - s6 = peg$c93; + s6 = peg$c94; peg$currPos++; } else { s6 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e106); } + if (peg$silentFails === 0) { peg$fail(peg$e107); } } if (s6 !== peg$FAILED) { peg$savedPos = s4; - s4 = peg$f91(s1); + s4 = peg$f92(s1); } else { peg$currPos = s4; s4 = peg$FAILED; @@ -3692,15 +3735,15 @@ function peg$parse(input, options) { } if (s5 !== peg$FAILED) { if (input.charCodeAt(peg$currPos) === 34) { - s6 = peg$c94; + s6 = peg$c95; peg$currPos++; } else { s6 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e107); } + if (peg$silentFails === 0) { peg$fail(peg$e108); } } if (s6 !== peg$FAILED) { peg$savedPos = s4; - s4 = peg$f92(s1); + s4 = peg$f93(s1); } else { peg$currPos = s4; s4 = peg$FAILED; @@ -3719,7 +3762,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s4 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e104); } + if (peg$silentFails === 0) { peg$fail(peg$e105); } } if (s4 === peg$FAILED) { s4 = peg$currPos; @@ -3735,15 +3778,15 @@ function peg$parse(input, options) { } if (s5 !== peg$FAILED) { if (input.charCodeAt(peg$currPos) === 8220) { - s6 = peg$c92; + s6 = peg$c93; peg$currPos++; } else { s6 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e105); } + if (peg$silentFails === 0) { peg$fail(peg$e106); } } if (s6 !== peg$FAILED) { peg$savedPos = s4; - s4 = peg$f90(s1); + s4 = peg$f91(s1); } else { peg$currPos = s4; s4 = peg$FAILED; @@ -3766,15 +3809,15 @@ function peg$parse(input, options) { } if (s5 !== peg$FAILED) { if (input.charCodeAt(peg$currPos) === 8221) { - s6 = peg$c93; + s6 = peg$c94; peg$currPos++; } else { s6 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e106); } + if (peg$silentFails === 0) { peg$fail(peg$e107); } } if (s6 !== peg$FAILED) { peg$savedPos = s4; - s4 = peg$f91(s1); + s4 = peg$f92(s1); } else { peg$currPos = s4; s4 = peg$FAILED; @@ -3797,15 +3840,15 @@ function peg$parse(input, options) { } if (s5 !== peg$FAILED) { if (input.charCodeAt(peg$currPos) === 34) { - s6 = peg$c94; + s6 = peg$c95; peg$currPos++; } else { s6 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e107); } + if (peg$silentFails === 0) { peg$fail(peg$e108); } } if (s6 !== peg$FAILED) { peg$savedPos = s4; - s4 = peg$f92(s1); + s4 = peg$f93(s1); } else { peg$currPos = s4; s4 = peg$FAILED; @@ -3821,7 +3864,7 @@ function peg$parse(input, options) { s4 = peg$parseclosingQuote(); if (s4 !== peg$FAILED) { peg$savedPos = s0; - s0 = peg$f93(s1, s3, s4); + s0 = peg$f94(s1, s3, s4); } else { peg$currPos = s0; s0 = peg$FAILED; @@ -3833,7 +3876,7 @@ function peg$parse(input, options) { peg$silentFails--; if (s0 === peg$FAILED) { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e101); } + if (peg$silentFails === 0) { peg$fail(peg$e102); } } return s0; @@ -3848,7 +3891,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e103); } + if (peg$silentFails === 0) { peg$fail(peg$e104); } } if (s1 !== peg$FAILED) { s2 = peg$currPos; @@ -3886,7 +3929,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s2 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e108); } + if (peg$silentFails === 0) { peg$fail(peg$e109); } } while (s2 !== peg$FAILED) { s1.push(s2); @@ -3895,7 +3938,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s2 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e108); } + if (peg$silentFails === 0) { peg$fail(peg$e109); } } } s2 = []; @@ -3904,7 +3947,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s3 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e109); } + if (peg$silentFails === 0) { peg$fail(peg$e110); } } while (s3 !== peg$FAILED) { s2.push(s3); @@ -3913,7 +3956,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s3 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e109); } + if (peg$silentFails === 0) { peg$fail(peg$e110); } } } s3 = []; @@ -3922,7 +3965,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s4 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e110); } + if (peg$silentFails === 0) { peg$fail(peg$e111); } } while (s4 !== peg$FAILED) { s3.push(s4); @@ -3931,7 +3974,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s4 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e110); } + if (peg$silentFails === 0) { peg$fail(peg$e111); } } } s4 = peg$parseoperator(); @@ -3950,7 +3993,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s2 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e111); } + if (peg$silentFails === 0) { peg$fail(peg$e112); } } while (s2 !== peg$FAILED) { s1.push(s2); @@ -3959,7 +4002,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s2 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e111); } + if (peg$silentFails === 0) { peg$fail(peg$e112); } } } s2 = peg$currPos; @@ -4006,7 +4049,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s0 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e112); } + if (peg$silentFails === 0) { peg$fail(peg$e113); } } } } @@ -4022,7 +4065,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s0 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e113); } + if (peg$silentFails === 0) { peg$fail(peg$e114); } } if (s0 === peg$FAILED) { s0 = peg$currPos; diff --git a/src/libs/SearchParser/baseRules.peggy b/src/libs/SearchParser/baseRules.peggy index 0b160800cf36..1a8c6d48e905 100644 --- a/src/libs/SearchParser/baseRules.peggy +++ b/src/libs/SearchParser/baseRules.peggy @@ -114,6 +114,7 @@ columnsValues / groupWithdrawn / groupWithdrawalId / groupCategory + / groupMonth perDiem = "per-diem"i &wordBoundary { return "perDiem"; } draft = ("drafts"i / "draft"i) &wordBoundary { return "drafts"; } @@ -138,6 +139,7 @@ groupBankAccount = "group-bank-account"i &wordBoundary { return "gr groupWithdrawn = "group-withdrawn"i &wordBoundary { return "groupWithdrawn"; } groupWithdrawalId = "group-withdrawal-id"i &wordBoundary { return "groupWithdrawalID"; } groupCategory = "group-category"i &wordBoundary { return "groupCategory"; } +groupMonth = "group-month"i &wordBoundary { return "groupMonth"; } operator "operator" diff --git a/src/libs/SearchParser/searchParser.js b/src/libs/SearchParser/searchParser.js index 9a38ff83617c..dd66d002472e 100644 --- a/src/libs/SearchParser/searchParser.js +++ b/src/libs/SearchParser/searchParser.js @@ -271,14 +271,15 @@ function peg$parse(input, options) { var peg$c84 = "group-withdrawn"; var peg$c85 = "group-withdrawal-id"; var peg$c86 = "group-category"; - var peg$c87 = "!="; - var peg$c88 = ">="; - var peg$c89 = ">"; - var peg$c90 = "<="; - var peg$c91 = "<"; - var peg$c92 = "\u201C"; - var peg$c93 = "\u201D"; - var peg$c94 = "\""; + var peg$c87 = "group-month"; + var peg$c88 = "!="; + var peg$c89 = ">="; + var peg$c90 = ">"; + var peg$c91 = "<="; + var peg$c92 = "<"; + var peg$c93 = "\u201C"; + var peg$c94 = "\u201D"; + var peg$c95 = "\""; var peg$r0 = /^[^ \t\r\n\xA0]/; var peg$r1 = /^[ \t\r\n\xA0,:=<>!]/; @@ -387,30 +388,31 @@ function peg$parse(input, options) { var peg$e89 = peg$literalExpectation("group-withdrawn", true); var peg$e90 = peg$literalExpectation("group-withdrawal-id", true); var peg$e91 = peg$literalExpectation("group-category", true); - var peg$e92 = peg$otherExpectation("operator"); - var peg$e93 = peg$classExpectation([":", "="], false, false); - var peg$e94 = peg$literalExpectation("!=", false); - var peg$e95 = peg$literalExpectation(">=", false); - var peg$e96 = peg$literalExpectation(">", false); - var peg$e97 = peg$literalExpectation("<=", false); - var peg$e98 = peg$literalExpectation("<", false); - var peg$e99 = peg$otherExpectation("word"); - var peg$e100 = peg$classExpectation([" ", ",", "\t", "\n", "\r", "\xA0"], true, false); - var peg$e101 = peg$otherExpectation("whitespace"); - var peg$e102 = peg$classExpectation([" ", "\t", "\r", "\n", "\xA0"], false, false); - var peg$e103 = peg$otherExpectation("quote"); - var peg$e104 = peg$classExpectation([" ", ",", "\"", "\u201D", "\u201C", "\t", "\n", "\r", "\xA0"], true, false); - var peg$e105 = peg$classExpectation(["\"", ["\u201C", "\u201D"]], false, false); - var peg$e106 = peg$classExpectation(["\"", "\u201D", "\u201C", "\r", "\n"], true, false); - var peg$e107 = peg$literalExpectation("\u201C", false); - var peg$e108 = peg$literalExpectation("\u201D", false); - var peg$e109 = peg$literalExpectation("\"", false); - var peg$e110 = peg$classExpectation([" ", "\t", "\n", "\r", "\xA0", ["a", "z"], ["A", "Z"], ["0", "9"]], false, false); - var peg$e111 = peg$classExpectation([["a", "z"], ["A", "Z"], ["0", "9"]], false, false); - var peg$e112 = peg$classExpectation([" ", "\t", "\n", "\r", "\xA0"], false, false); - var peg$e113 = peg$classExpectation([" ", "\t", "\n", "\r", "\xA0", ["a", "z"], ["A", "Z"]], false, false); - var peg$e114 = peg$classExpectation([","], false, false); - var peg$e115 = peg$classExpectation([" ", "\t", "\n", "\r", "\xA0", ","], false, false); + var peg$e92 = peg$literalExpectation("group-month", true); + var peg$e93 = peg$otherExpectation("operator"); + var peg$e94 = peg$classExpectation([":", "="], false, false); + var peg$e95 = peg$literalExpectation("!=", false); + var peg$e96 = peg$literalExpectation(">=", false); + var peg$e97 = peg$literalExpectation(">", false); + var peg$e98 = peg$literalExpectation("<=", false); + var peg$e99 = peg$literalExpectation("<", false); + var peg$e100 = peg$otherExpectation("word"); + var peg$e101 = peg$classExpectation([" ", ",", "\t", "\n", "\r", "\xA0"], true, false); + var peg$e102 = peg$otherExpectation("whitespace"); + var peg$e103 = peg$classExpectation([" ", "\t", "\r", "\n", "\xA0"], false, false); + var peg$e104 = peg$otherExpectation("quote"); + var peg$e105 = peg$classExpectation([" ", ",", "\"", "\u201D", "\u201C", "\t", "\n", "\r", "\xA0"], true, false); + var peg$e106 = peg$classExpectation(["\"", ["\u201C", "\u201D"]], false, false); + var peg$e107 = peg$classExpectation(["\"", "\u201D", "\u201C", "\r", "\n"], true, false); + var peg$e108 = peg$literalExpectation("\u201C", false); + var peg$e109 = peg$literalExpectation("\u201D", false); + var peg$e110 = peg$literalExpectation("\"", false); + var peg$e111 = peg$classExpectation([" ", "\t", "\n", "\r", "\xA0", ["a", "z"], ["A", "Z"], ["0", "9"]], false, false); + var peg$e112 = peg$classExpectation([["a", "z"], ["A", "Z"], ["0", "9"]], false, false); + var peg$e113 = peg$classExpectation([" ", "\t", "\n", "\r", "\xA0"], false, false); + var peg$e114 = peg$classExpectation([" ", "\t", "\n", "\r", "\xA0", ["a", "z"], ["A", "Z"]], false, false); + var peg$e115 = peg$classExpectation([","], false, false); + var peg$e116 = peg$classExpectation([" ", "\t", "\n", "\r", "\xA0", ","], false, false); var peg$f0 = function(filters) { return applyDefaults(filters); }; var peg$f1 = function(head, tail) { @@ -572,32 +574,33 @@ function peg$parse(input, options) { var peg$f77 = function() { return "groupWithdrawn"; }; var peg$f78 = function() { return "groupWithdrawalID"; }; var peg$f79 = function() { return "groupCategory"; }; - var peg$f80 = function() { return "eq"; }; - var peg$f81 = function() { return "neq"; }; - var peg$f82 = function() { return "gte"; }; - var peg$f83 = function() { return "gt"; }; - var peg$f84 = function() { return "lte"; }; - var peg$f85 = function() { return "lt"; }; - var peg$f86 = function(o) { + var peg$f80 = function() { return "groupMonth"; }; + var peg$f81 = function() { return "eq"; }; + var peg$f82 = function() { return "neq"; }; + var peg$f83 = function() { return "gte"; }; + var peg$f84 = function() { return "gt"; }; + var peg$f85 = function() { return "lte"; }; + var peg$f86 = function() { return "lt"; }; + var peg$f87 = function(o) { if (nameOperator) { expectingNestedQuote = (o === "eq"); // Use simple parser if no valid operator is found } isColumnsContext = false; return o; }; - var peg$f87 = function(chars) { return chars.join("").trim(); }; - var peg$f88 = function() { + var peg$f88 = function(chars) { return chars.join("").trim(); }; + var peg$f89 = function() { isColumnsContext = false; return "and"; }; - var peg$f89 = function() { return expectingNestedQuote; }; - var peg$f90 = function(start, inner, end) { //handle no-breaking space + var peg$f90 = function() { return expectingNestedQuote; }; + var peg$f91 = function(start, inner, end) { //handle no-breaking space return [...start, '"', ...inner, '"', ...end].join(""); }; - var peg$f91 = function(start) {return "“"}; - var peg$f92 = function(start) {return "”"}; - var peg$f93 = function(start) {return "\""}; - var peg$f94 = function(start, inner, end) { + var peg$f92 = function(start) {return "“"}; + var peg$f93 = function(start) {return "”"}; + var peg$f94 = function(start) {return "\""}; + var peg$f95 = function(start, inner, end) { return [...start, '"', ...inner, '"'].join(""); }; var peg$currPos = options.peg$currPos | 0; @@ -2558,6 +2561,9 @@ function peg$parse(input, options) { s0 = peg$parsegroupWithdrawalId(); if (s0 === peg$FAILED) { s0 = peg$parsegroupCategory(); + if (s0 === peg$FAILED) { + s0 = peg$parsegroupMonth(); + } } } } @@ -3442,6 +3448,43 @@ function peg$parse(input, options) { return s0; } + function peg$parsegroupMonth() { + var s0, s1, s2, s3; + + s0 = peg$currPos; + s1 = input.substr(peg$currPos, 11); + if (s1.toLowerCase() === peg$c87) { + peg$currPos += 11; + } else { + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e92); } + } + if (s1 !== peg$FAILED) { + s2 = peg$currPos; + peg$silentFails++; + s3 = peg$parsewordBoundary(); + peg$silentFails--; + if (s3 !== peg$FAILED) { + peg$currPos = s2; + s2 = undefined; + } else { + s2 = peg$FAILED; + } + if (s2 !== peg$FAILED) { + peg$savedPos = s0; + s0 = peg$f80(); + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + + return s0; + } + function peg$parseoperator() { var s0, s1; @@ -3452,81 +3495,81 @@ function peg$parse(input, options) { peg$currPos++; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e93); } + if (peg$silentFails === 0) { peg$fail(peg$e94); } } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f80(); + s1 = peg$f81(); } s0 = s1; if (s0 === peg$FAILED) { s0 = peg$currPos; - if (input.substr(peg$currPos, 2) === peg$c87) { - s1 = peg$c87; + if (input.substr(peg$currPos, 2) === peg$c88) { + s1 = peg$c88; peg$currPos += 2; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e94); } + if (peg$silentFails === 0) { peg$fail(peg$e95); } } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f81(); + s1 = peg$f82(); } s0 = s1; if (s0 === peg$FAILED) { s0 = peg$currPos; - if (input.substr(peg$currPos, 2) === peg$c88) { - s1 = peg$c88; + if (input.substr(peg$currPos, 2) === peg$c89) { + s1 = peg$c89; peg$currPos += 2; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e95); } + if (peg$silentFails === 0) { peg$fail(peg$e96); } } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f82(); + s1 = peg$f83(); } s0 = s1; if (s0 === peg$FAILED) { s0 = peg$currPos; if (input.charCodeAt(peg$currPos) === 62) { - s1 = peg$c89; + s1 = peg$c90; peg$currPos++; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e96); } + if (peg$silentFails === 0) { peg$fail(peg$e97); } } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f83(); + s1 = peg$f84(); } s0 = s1; if (s0 === peg$FAILED) { s0 = peg$currPos; - if (input.substr(peg$currPos, 2) === peg$c90) { - s1 = peg$c90; + if (input.substr(peg$currPos, 2) === peg$c91) { + s1 = peg$c91; peg$currPos += 2; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e97); } + if (peg$silentFails === 0) { peg$fail(peg$e98); } } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f84(); + s1 = peg$f85(); } s0 = s1; if (s0 === peg$FAILED) { s0 = peg$currPos; if (input.charCodeAt(peg$currPos) === 60) { - s1 = peg$c91; + s1 = peg$c92; peg$currPos++; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e98); } + if (peg$silentFails === 0) { peg$fail(peg$e99); } } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f85(); + s1 = peg$f86(); } s0 = s1; } @@ -3537,7 +3580,7 @@ function peg$parse(input, options) { peg$silentFails--; if (s0 === peg$FAILED) { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e92); } + if (peg$silentFails === 0) { peg$fail(peg$e93); } } return s0; @@ -3550,7 +3593,7 @@ function peg$parse(input, options) { s1 = peg$parseoperator(); if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f86(s1); + s1 = peg$f87(s1); } s0 = s1; @@ -3568,7 +3611,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s2 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e100); } + if (peg$silentFails === 0) { peg$fail(peg$e101); } } if (s2 !== peg$FAILED) { while (s2 !== peg$FAILED) { @@ -3578,7 +3621,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s2 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e100); } + if (peg$silentFails === 0) { peg$fail(peg$e101); } } } } else { @@ -3586,13 +3629,13 @@ function peg$parse(input, options) { } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f87(s1); + s1 = peg$f88(s1); } s0 = s1; peg$silentFails--; if (s0 === peg$FAILED) { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e99); } + if (peg$silentFails === 0) { peg$fail(peg$e100); } } return s0; @@ -3604,7 +3647,7 @@ function peg$parse(input, options) { s0 = peg$currPos; s1 = peg$parse_(); peg$savedPos = s0; - s1 = peg$f88(); + s1 = peg$f89(); s0 = s1; return s0; @@ -3620,7 +3663,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e102); } + if (peg$silentFails === 0) { peg$fail(peg$e103); } } while (s1 !== peg$FAILED) { s0.push(s1); @@ -3629,12 +3672,12 @@ function peg$parse(input, options) { peg$currPos++; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e102); } + if (peg$silentFails === 0) { peg$fail(peg$e103); } } } peg$silentFails--; s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e101); } + if (peg$silentFails === 0) { peg$fail(peg$e102); } return s0; } @@ -3644,7 +3687,7 @@ function peg$parse(input, options) { s0 = peg$currPos; peg$savedPos = peg$currPos; - s1 = peg$f89(); + s1 = peg$f90(); if (s1) { s1 = undefined; } else { @@ -3681,7 +3724,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s2 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e104); } + if (peg$silentFails === 0) { peg$fail(peg$e105); } } while (s2 !== peg$FAILED) { s1.push(s2); @@ -3690,7 +3733,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s2 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e104); } + if (peg$silentFails === 0) { peg$fail(peg$e105); } } } s2 = input.charAt(peg$currPos); @@ -3698,7 +3741,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s2 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e105); } + if (peg$silentFails === 0) { peg$fail(peg$e106); } } if (s2 !== peg$FAILED) { s3 = []; @@ -3707,7 +3750,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s4 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e106); } + if (peg$silentFails === 0) { peg$fail(peg$e107); } } while (s4 !== peg$FAILED) { s3.push(s4); @@ -3716,7 +3759,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s4 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e106); } + if (peg$silentFails === 0) { peg$fail(peg$e107); } } } s4 = input.charAt(peg$currPos); @@ -3724,7 +3767,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s4 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e105); } + if (peg$silentFails === 0) { peg$fail(peg$e106); } } if (s4 !== peg$FAILED) { s5 = []; @@ -3733,7 +3776,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s6 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e100); } + if (peg$silentFails === 0) { peg$fail(peg$e101); } } while (s6 !== peg$FAILED) { s5.push(s6); @@ -3742,11 +3785,11 @@ function peg$parse(input, options) { peg$currPos++; } else { s6 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e100); } + if (peg$silentFails === 0) { peg$fail(peg$e101); } } } peg$savedPos = s0; - s0 = peg$f90(s1, s3, s5); + s0 = peg$f91(s1, s3, s5); } else { peg$currPos = s0; s0 = peg$FAILED; @@ -3758,7 +3801,7 @@ function peg$parse(input, options) { peg$silentFails--; if (s0 === peg$FAILED) { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e103); } + if (peg$silentFails === 0) { peg$fail(peg$e104); } } return s0; @@ -3775,7 +3818,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s2 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e104); } + if (peg$silentFails === 0) { peg$fail(peg$e105); } } while (s2 !== peg$FAILED) { s1.push(s2); @@ -3784,7 +3827,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s2 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e104); } + if (peg$silentFails === 0) { peg$fail(peg$e105); } } } s2 = input.charAt(peg$currPos); @@ -3792,7 +3835,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s2 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e105); } + if (peg$silentFails === 0) { peg$fail(peg$e106); } } if (s2 !== peg$FAILED) { s3 = []; @@ -3801,7 +3844,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s4 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e106); } + if (peg$silentFails === 0) { peg$fail(peg$e107); } } if (s4 === peg$FAILED) { s4 = peg$currPos; @@ -3817,15 +3860,15 @@ function peg$parse(input, options) { } if (s5 !== peg$FAILED) { if (input.charCodeAt(peg$currPos) === 8220) { - s6 = peg$c92; + s6 = peg$c93; peg$currPos++; } else { s6 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e107); } + if (peg$silentFails === 0) { peg$fail(peg$e108); } } if (s6 !== peg$FAILED) { peg$savedPos = s4; - s4 = peg$f91(s1); + s4 = peg$f92(s1); } else { peg$currPos = s4; s4 = peg$FAILED; @@ -3848,15 +3891,15 @@ function peg$parse(input, options) { } if (s5 !== peg$FAILED) { if (input.charCodeAt(peg$currPos) === 8221) { - s6 = peg$c93; + s6 = peg$c94; peg$currPos++; } else { s6 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e108); } + if (peg$silentFails === 0) { peg$fail(peg$e109); } } if (s6 !== peg$FAILED) { peg$savedPos = s4; - s4 = peg$f92(s1); + s4 = peg$f93(s1); } else { peg$currPos = s4; s4 = peg$FAILED; @@ -3879,15 +3922,15 @@ function peg$parse(input, options) { } if (s5 !== peg$FAILED) { if (input.charCodeAt(peg$currPos) === 34) { - s6 = peg$c94; + s6 = peg$c95; peg$currPos++; } else { s6 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e109); } + if (peg$silentFails === 0) { peg$fail(peg$e110); } } if (s6 !== peg$FAILED) { peg$savedPos = s4; - s4 = peg$f93(s1); + s4 = peg$f94(s1); } else { peg$currPos = s4; s4 = peg$FAILED; @@ -3906,7 +3949,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s4 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e106); } + if (peg$silentFails === 0) { peg$fail(peg$e107); } } if (s4 === peg$FAILED) { s4 = peg$currPos; @@ -3922,15 +3965,15 @@ function peg$parse(input, options) { } if (s5 !== peg$FAILED) { if (input.charCodeAt(peg$currPos) === 8220) { - s6 = peg$c92; + s6 = peg$c93; peg$currPos++; } else { s6 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e107); } + if (peg$silentFails === 0) { peg$fail(peg$e108); } } if (s6 !== peg$FAILED) { peg$savedPos = s4; - s4 = peg$f91(s1); + s4 = peg$f92(s1); } else { peg$currPos = s4; s4 = peg$FAILED; @@ -3953,15 +3996,15 @@ function peg$parse(input, options) { } if (s5 !== peg$FAILED) { if (input.charCodeAt(peg$currPos) === 8221) { - s6 = peg$c93; + s6 = peg$c94; peg$currPos++; } else { s6 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e108); } + if (peg$silentFails === 0) { peg$fail(peg$e109); } } if (s6 !== peg$FAILED) { peg$savedPos = s4; - s4 = peg$f92(s1); + s4 = peg$f93(s1); } else { peg$currPos = s4; s4 = peg$FAILED; @@ -3984,15 +4027,15 @@ function peg$parse(input, options) { } if (s5 !== peg$FAILED) { if (input.charCodeAt(peg$currPos) === 34) { - s6 = peg$c94; + s6 = peg$c95; peg$currPos++; } else { s6 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e109); } + if (peg$silentFails === 0) { peg$fail(peg$e110); } } if (s6 !== peg$FAILED) { peg$savedPos = s4; - s4 = peg$f93(s1); + s4 = peg$f94(s1); } else { peg$currPos = s4; s4 = peg$FAILED; @@ -4008,7 +4051,7 @@ function peg$parse(input, options) { s4 = peg$parseclosingQuote(); if (s4 !== peg$FAILED) { peg$savedPos = s0; - s0 = peg$f94(s1, s3, s4); + s0 = peg$f95(s1, s3, s4); } else { peg$currPos = s0; s0 = peg$FAILED; @@ -4020,7 +4063,7 @@ function peg$parse(input, options) { peg$silentFails--; if (s0 === peg$FAILED) { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e103); } + if (peg$silentFails === 0) { peg$fail(peg$e104); } } return s0; @@ -4035,7 +4078,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e105); } + if (peg$silentFails === 0) { peg$fail(peg$e106); } } if (s1 !== peg$FAILED) { s2 = peg$currPos; @@ -4073,7 +4116,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s2 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e110); } + if (peg$silentFails === 0) { peg$fail(peg$e111); } } while (s2 !== peg$FAILED) { s1.push(s2); @@ -4082,7 +4125,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s2 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e110); } + if (peg$silentFails === 0) { peg$fail(peg$e111); } } } s2 = []; @@ -4091,7 +4134,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s3 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e111); } + if (peg$silentFails === 0) { peg$fail(peg$e112); } } while (s3 !== peg$FAILED) { s2.push(s3); @@ -4100,7 +4143,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s3 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e111); } + if (peg$silentFails === 0) { peg$fail(peg$e112); } } } s3 = []; @@ -4109,7 +4152,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s4 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e112); } + if (peg$silentFails === 0) { peg$fail(peg$e113); } } while (s4 !== peg$FAILED) { s3.push(s4); @@ -4118,7 +4161,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s4 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e112); } + if (peg$silentFails === 0) { peg$fail(peg$e113); } } } s4 = peg$parseoperator(); @@ -4137,7 +4180,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s2 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e113); } + if (peg$silentFails === 0) { peg$fail(peg$e114); } } while (s2 !== peg$FAILED) { s1.push(s2); @@ -4146,7 +4189,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s2 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e113); } + if (peg$silentFails === 0) { peg$fail(peg$e114); } } } s2 = peg$currPos; @@ -4193,7 +4236,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s0 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e114); } + if (peg$silentFails === 0) { peg$fail(peg$e115); } } } } @@ -4209,7 +4252,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s0 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e115); } + if (peg$silentFails === 0) { peg$fail(peg$e116); } } if (s0 === peg$FAILED) { s0 = peg$currPos; @@ -4239,6 +4282,7 @@ function peg$parse(input, options) { card: "card", "withdrawal-id": "withdrawn", category: "category", + month: "groupmonth", }; const GROUP_BY_DEFAULT_SORT_ORDER = { @@ -4246,6 +4290,7 @@ function peg$parse(input, options) { card: "asc", "withdrawal-id": "desc", category: "asc", + month: "desc" }; const DEFAULT_SORT_BY_VALUES = new Set([...Object.values(GROUP_BY_DEFAULT_SORT), "date"]); diff --git a/src/libs/SearchParser/searchParser.peggy b/src/libs/SearchParser/searchParser.peggy index 6fe5e1ceb344..9dc46092f4cf 100644 --- a/src/libs/SearchParser/searchParser.peggy +++ b/src/libs/SearchParser/searchParser.peggy @@ -33,6 +33,7 @@ card: "card", "withdrawal-id": "withdrawn", category: "category", + month: "groupmonth", }; const GROUP_BY_DEFAULT_SORT_ORDER = { @@ -40,6 +41,7 @@ card: "asc", "withdrawal-id": "desc", category: "asc", + month: "desc" }; const DEFAULT_SORT_BY_VALUES = new Set([...Object.values(GROUP_BY_DEFAULT_SORT), "date"]); diff --git a/src/libs/SearchUIUtils.ts b/src/libs/SearchUIUtils.ts index cfce77b21be1..1bff7d6166e4 100644 --- a/src/libs/SearchUIUtils.ts +++ b/src/libs/SearchUIUtils.ts @@ -1,3 +1,4 @@ +import {format} from 'date-fns'; import type {TextStyle, ViewStyle} from 'react-native'; import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; import type {ValueOf} from 'type-fest'; @@ -36,7 +37,9 @@ import type { TransactionGroupListItemType, TransactionListItemType, TransactionMemberGroupListItemType, + TransactionMonthGroupListItemType, TransactionReportGroupListItemType, + TransactionTagGroupListItemType, TransactionWithdrawalIDGroupListItemType, } from '@components/SelectionListWithSections/types'; import type {ThemeColors} from '@styles/theme/types'; @@ -56,6 +59,7 @@ import type { SearchCategoryGroup, SearchDataTypes, SearchMemberGroup, + SearchTagGroup, SearchTask, SearchTransactionAction, SearchWithdrawalIDGroup, @@ -149,6 +153,8 @@ type TransactionMemberGroupSorting = ColumnSortMapping; type TransactionWithdrawalIDGroupSorting = ColumnSortMapping; type TransactionCategoryGroupSorting = ColumnSortMapping; +type TransactionTagGroupSorting = ColumnSortMapping; +type TransactionMonthGroupSorting = ColumnSortMapping; type GetReportSectionsParams = { data: OnyxTypes.SearchResults['data']; @@ -209,12 +215,17 @@ const expenseReportColumnNamesToSortingProperty: ExpenseReportSorting = { [CONST.SEARCH.TABLE_COLUMNS.ACTION]: 'action' as const, }; +// Base sorting properties common to all transaction group types +const transactionGroupBaseSortingProperties = { + [CONST.SEARCH.TABLE_COLUMNS.GROUP_EXPENSES]: 'count' as const, + [CONST.SEARCH.TABLE_COLUMNS.GROUP_TOTAL]: 'total' as const, +}; + const transactionMemberGroupColumnNamesToSortingProperty: TransactionMemberGroupSorting = { [CONST.SEARCH.TABLE_COLUMNS.AVATAR]: null, [CONST.SEARCH.TABLE_COLUMNS.GROUP_FROM]: 'formattedFrom' as const, [CONST.SEARCH.TABLE_COLUMNS.FROM]: 'formattedFrom' as const, - [CONST.SEARCH.TABLE_COLUMNS.GROUP_EXPENSES]: 'count' as const, - [CONST.SEARCH.TABLE_COLUMNS.GROUP_TOTAL]: 'total' as const, + ...transactionGroupBaseSortingProperties, }; const transactionCardGroupColumnNamesToSortingProperty: TransactionCardGroupSorting = { @@ -222,8 +233,7 @@ const transactionCardGroupColumnNamesToSortingProperty: TransactionCardGroupSort [CONST.SEARCH.TABLE_COLUMNS.GROUP_CARD]: 'formattedCardName' as const, [CONST.SEARCH.TABLE_COLUMNS.CARD]: 'formattedCardName' as const, [CONST.SEARCH.TABLE_COLUMNS.GROUP_FEED]: 'formattedFeedName' as const, - [CONST.SEARCH.TABLE_COLUMNS.GROUP_EXPENSES]: 'count' as const, - [CONST.SEARCH.TABLE_COLUMNS.GROUP_TOTAL]: 'total' as const, + ...transactionGroupBaseSortingProperties, }; const transactionWithdrawalIDGroupColumnNamesToSortingProperty: TransactionWithdrawalIDGroupSorting = { @@ -232,13 +242,23 @@ const transactionWithdrawalIDGroupColumnNamesToSortingProperty: TransactionWithd [CONST.SEARCH.TABLE_COLUMNS.GROUP_WITHDRAWN]: 'debitPosted' as const, [CONST.SEARCH.TABLE_COLUMNS.WITHDRAWN]: 'debitPosted' as const, [CONST.SEARCH.TABLE_COLUMNS.GROUP_WITHDRAWAL_ID]: 'formattedWithdrawalID' as const, - [CONST.SEARCH.TABLE_COLUMNS.GROUP_EXPENSES]: 'count' as const, - [CONST.SEARCH.TABLE_COLUMNS.GROUP_TOTAL]: 'total' as const, + ...transactionGroupBaseSortingProperties, }; const transactionCategoryGroupColumnNamesToSortingProperty: TransactionCategoryGroupSorting = { [CONST.SEARCH.TABLE_COLUMNS.GROUP_CATEGORY]: 'formattedCategory' as const, [CONST.SEARCH.TABLE_COLUMNS.CATEGORY]: 'formattedCategory' as const, + ...transactionGroupBaseSortingProperties, +}; + +const transactionTagGroupColumnNamesToSortingProperty: TransactionTagGroupSorting = { + [CONST.SEARCH.TABLE_COLUMNS.GROUP_TAG]: 'formattedTag' as const, + [CONST.SEARCH.TABLE_COLUMNS.TAG]: 'formattedTag' as const, + ...transactionGroupBaseSortingProperties, +}; + +const transactionMonthGroupColumnNamesToSortingProperty: TransactionMonthGroupSorting = { + [CONST.SEARCH.TABLE_COLUMNS.GROUP_MONTH]: 'sortKey' as const, [CONST.SEARCH.TABLE_COLUMNS.GROUP_EXPENSES]: 'count' as const, [CONST.SEARCH.TABLE_COLUMNS.GROUP_TOTAL]: 'total' as const, }; @@ -919,6 +939,20 @@ function isTransactionCategoryGroupListItemType(item: ListItem): item is Transac return isTransactionGroupListItemType(item) && 'groupedBy' in item && item.groupedBy === CONST.SEARCH.GROUP_BY.CATEGORY; } +/** + * Type guard that checks if something is a TransactionTagGroupListItemType + */ +function isTransactionTagGroupListItemType(item: ListItem): item is TransactionTagGroupListItemType { + return isTransactionGroupListItemType(item) && 'groupedBy' in item && item.groupedBy === CONST.SEARCH.GROUP_BY.TAG; +} + +/** + * Type guard that checks if something is a TransactionMonthGroupListItemType + */ +function isTransactionMonthGroupListItemType(item: ListItem): item is TransactionMonthGroupListItemType { + return isTransactionGroupListItemType(item) && 'groupedBy' in item && item.groupedBy === CONST.SEARCH.GROUP_BY.MONTH; +} + /** * Type guard that checks if something is a TransactionListItemType */ @@ -1288,8 +1322,13 @@ function getToFieldValueForTransaction( const isIOUReport = report?.type === CONST.REPORT.TYPE.IOU; if (isIOUReport) { return ( - getIOUPayerAndReceiver(report?.managerID ?? CONST.DEFAULT_NUMBER_ID, report?.ownerAccountID ?? CONST.DEFAULT_NUMBER_ID, personalDetailsList, transactionItem.amount)?.to ?? - emptyPersonalDetails + getIOUPayerAndReceiver( + report?.managerID ?? CONST.DEFAULT_NUMBER_ID, + report?.ownerAccountID ?? CONST.DEFAULT_NUMBER_ID, + personalDetailsList, + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + Number(transactionItem.modifiedAmount) || transactionItem.amount, + )?.to ?? emptyPersonalDetails ); } return personalDetailsList?.[report?.managerID] ?? emptyPersonalDetails; @@ -2178,6 +2217,104 @@ function getCategorySections(data: OnyxTypes.SearchResults['data'], queryJSON: S return [categorySectionsValues, categorySectionsValues.length]; } +/** + * @private + * Organizes data into List Sections grouped by tag for display, for the TransactionGroupListItemType of Search Results. + * + * Do not use directly, use only via `getSections()` facade. + */ +function getTagSections(data: OnyxTypes.SearchResults['data'], queryJSON: SearchQueryJSON | undefined, translate: LocalizedTranslate): [TransactionTagGroupListItemType[], number] { + const tagSections: Record = {}; + + for (const key in data) { + if (isGroupEntry(key)) { + const tagGroup = data[key] as SearchTagGroup; + + let transactionsQueryJSON: SearchQueryJSON | undefined; + if (queryJSON && tagGroup.tag !== undefined) { + // Normalize empty tag or "(untagged)" to TAG_EMPTY_VALUE to avoid invalid query like "tag:" + const tagValue = tagGroup.tag === '' || tagGroup.tag === '(untagged)' ? CONST.SEARCH.TAG_EMPTY_VALUE : tagGroup.tag; + + const newFlatFilters = queryJSON.flatFilters.filter((filter) => filter.key !== CONST.SEARCH.SYNTAX_FILTER_KEYS.TAG); + newFlatFilters.push({key: CONST.SEARCH.SYNTAX_FILTER_KEYS.TAG, filters: [{operator: CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO, value: tagValue}]}); + + const newQueryJSON: SearchQueryJSON = {...queryJSON, groupBy: undefined, flatFilters: newFlatFilters}; + + const newQuery = buildSearchQueryString(newQueryJSON); + + transactionsQueryJSON = buildSearchQueryJSON(newQuery); + } + + // Format the tag name - use translated "No tag" for empty values so it sorts alphabetically + const rawTag = tagGroup.tag; + const isEmptyTag = !rawTag || rawTag === CONST.SEARCH.TAG_EMPTY_VALUE || rawTag === '(untagged)'; + const formattedTag = isEmptyTag ? translate('search.noTag') : rawTag; + + tagSections[key] = { + groupedBy: CONST.SEARCH.GROUP_BY.TAG, + transactions: [], + transactionsQueryJSON, + ...tagGroup, + formattedTag, + }; + } + } + + const tagSectionsValues = Object.values(tagSections); + return [tagSectionsValues, tagSectionsValues.length]; +} + +/** + * @private + * Organizes data into List Sections grouped by month for display, for the TransactionGroupListItemType of Search Results. + * + * Do not use directly, use only via `getSections()` facade. + */ +function getMonthSections(data: OnyxTypes.SearchResults['data'], queryJSON: SearchQueryJSON | undefined): [TransactionMonthGroupListItemType[], number] { + const monthSections: Record = {}; + for (const key in data) { + if (isGroupEntry(key)) { + const monthGroup = data[key]; + // Check if this is a month group by checking for year and month properties + if (!('year' in monthGroup) || !('month' in monthGroup)) { + continue; + } + let transactionsQueryJSON: SearchQueryJSON | undefined; + if (queryJSON && monthGroup.year && monthGroup.month) { + // Create date range for the month (first day to last day of the month) + const {start: monthStart, end: monthEnd} = DateUtils.getMonthDateRange(monthGroup.year, monthGroup.month); + const newFlatFilters = queryJSON.flatFilters.filter((filter) => filter.key !== CONST.SEARCH.SYNTAX_FILTER_KEYS.DATE); + newFlatFilters.push({ + key: CONST.SEARCH.SYNTAX_FILTER_KEYS.DATE, + filters: [ + {operator: CONST.SEARCH.SYNTAX_OPERATORS.GREATER_THAN_OR_EQUAL_TO, value: monthStart}, + {operator: CONST.SEARCH.SYNTAX_OPERATORS.LOWER_THAN_OR_EQUAL_TO, value: monthEnd}, + ], + }); + const newQueryJSON: SearchQueryJSON = {...queryJSON, groupBy: undefined, flatFilters: newFlatFilters}; + const newQuery = buildSearchQueryString(newQueryJSON); + transactionsQueryJSON = buildSearchQueryJSON(newQuery); + } + + // Format month display: "January 2026" + const monthDate = new Date(monthGroup.year, monthGroup.month - 1, 1); + const formattedMonth = format(monthDate, 'MMMM yyyy'); + + monthSections[key] = { + groupedBy: CONST.SEARCH.GROUP_BY.MONTH, + transactions: [], + transactionsQueryJSON, + ...monthGroup, + formattedMonth, + sortKey: monthGroup.year * 100 + monthGroup.month, + }; + } + } + + const monthSectionsValues = Object.values(monthSections); + return [monthSectionsValues, monthSectionsValues.length]; +} + /** * Returns the appropriate list item component based on the type and status of the search data. */ @@ -2253,6 +2390,10 @@ function getSections({ return getWithdrawalIDSections(data, queryJSON); case CONST.SEARCH.GROUP_BY.CATEGORY: return getCategorySections(data, queryJSON); + case CONST.SEARCH.GROUP_BY.TAG: + return getTagSections(data, queryJSON, translate); + case CONST.SEARCH.GROUP_BY.MONTH: + return getMonthSections(data, queryJSON); } } @@ -2294,6 +2435,10 @@ function getSortedSections( return getSortedWithdrawalIDData(data as TransactionWithdrawalIDGroupListItemType[], localeCompare, sortBy, sortOrder); case CONST.SEARCH.GROUP_BY.CATEGORY: return getSortedCategoryData(data as TransactionCategoryGroupListItemType[], localeCompare, sortBy, sortOrder); + case CONST.SEARCH.GROUP_BY.TAG: + return getSortedTagData(data as TransactionTagGroupListItemType[], localeCompare, sortBy, sortOrder); + case CONST.SEARCH.GROUP_BY.MONTH: + return getSortedMonthData(data as TransactionMonthGroupListItemType[], localeCompare, sortBy, sortOrder); } } @@ -2665,6 +2810,22 @@ function getSortedCategoryData(data: TransactionCategoryGroupListItemType[], loc ); } +/** + * @private + * Sorts tag sections based on a specified column and sort order. + */ +function getSortedTagData(data: TransactionTagGroupListItemType[], localeCompare: LocaleContextProps['localeCompare'], sortBy?: SearchColumnType, sortOrder?: SortOrder) { + return getSortedData(data, localeCompare, transactionTagGroupColumnNamesToSortingProperty, (a, b) => localeCompare(a.formattedTag ?? '', b.formattedTag ?? ''), sortBy, sortOrder); +} + +/** + * @private + * Sorts month sections based on a specified column and sort order. + */ +function getSortedMonthData(data: TransactionMonthGroupListItemType[], localeCompare: LocaleContextProps['localeCompare'], sortBy?: SearchColumnType, sortOrder?: SortOrder) { + return getSortedData(data, localeCompare, transactionMonthGroupColumnNamesToSortingProperty, (a, b) => a.sortKey - b.sortKey, sortBy, sortOrder); +} + /** * @private * Sorts report actions sections based on a specified column and sort order. @@ -2718,6 +2879,10 @@ function getCustomColumns(value?: SearchDataTypes | SearchGroupBy): SearchCustom return Object.values(CONST.SEARCH.GROUP_CUSTOM_COLUMNS.WITHDRAWAL_ID); case CONST.SEARCH.GROUP_BY.CATEGORY: return Object.values(CONST.SEARCH.GROUP_CUSTOM_COLUMNS.CATEGORY); + case CONST.SEARCH.GROUP_BY.TAG: + return Object.values(CONST.SEARCH.GROUP_CUSTOM_COLUMNS.TAG); + case CONST.SEARCH.GROUP_BY.MONTH: + return Object.values(CONST.SEARCH.GROUP_CUSTOM_COLUMNS.MONTH); default: return []; } @@ -2745,6 +2910,10 @@ function getCustomColumnDefault(value?: SearchDataTypes | SearchGroupBy): Search return CONST.SEARCH.GROUP_DEFAULT_COLUMNS.WITHDRAWAL_ID; case CONST.SEARCH.GROUP_BY.CATEGORY: return CONST.SEARCH.GROUP_DEFAULT_COLUMNS.CATEGORY; + case CONST.SEARCH.GROUP_BY.TAG: + return CONST.SEARCH.GROUP_DEFAULT_COLUMNS.TAG; + case CONST.SEARCH.GROUP_BY.MONTH: + return CONST.SEARCH.GROUP_DEFAULT_COLUMNS.MONTH; default: return []; } @@ -2823,12 +2992,16 @@ function getSearchColumnTranslationKey(columnId: SearchCustomColumnIds): Transla return 'common.total'; case CONST.SEARCH.TABLE_COLUMNS.GROUP_WITHDRAWAL_ID: return 'common.withdrawalID'; + case CONST.SEARCH.TABLE_COLUMNS.GROUP_MONTH: + return 'common.month'; case CONST.SEARCH.TABLE_COLUMNS.GROUP_WITHDRAWN: return 'search.filters.withdrawn'; case CONST.SEARCH.TABLE_COLUMNS.GROUP_FEED: return 'search.filters.feed'; case CONST.SEARCH.TABLE_COLUMNS.GROUP_CATEGORY: return 'common.category'; + case CONST.SEARCH.TABLE_COLUMNS.GROUP_TAG: + return 'common.tag'; case CONST.SEARCH.TABLE_COLUMNS.EXPORTED_TO: return 'search.exportedTo'; } @@ -3220,7 +3393,7 @@ function getDatePresets(filterKey: SearchDateFilterKeys, hasFeed: boolean): Sear case CONST.SEARCH.SYNTAX_FILTER_KEYS.POSTED: return [CONST.SEARCH.DATE_PRESETS.THIS_MONTH, CONST.SEARCH.DATE_PRESETS.LAST_MONTH, ...(hasFeed ? [CONST.SEARCH.DATE_PRESETS.LAST_STATEMENT] : [])]; case CONST.SEARCH.SYNTAX_FILTER_KEYS.DATE: - return [CONST.SEARCH.DATE_PRESETS.THIS_MONTH, CONST.SEARCH.DATE_PRESETS.LAST_MONTH]; + return [CONST.SEARCH.DATE_PRESETS.THIS_MONTH, CONST.SEARCH.DATE_PRESETS.LAST_MONTH, CONST.SEARCH.DATE_PRESETS.YEAR_TO_DATE]; default: return defaultPresets; } @@ -3306,6 +3479,8 @@ function getColumnsToShow( [CONST.SEARCH.GROUP_BY.FROM]: CONST.SEARCH.GROUP_CUSTOM_COLUMNS.FROM, [CONST.SEARCH.GROUP_BY.WITHDRAWAL_ID]: CONST.SEARCH.GROUP_CUSTOM_COLUMNS.WITHDRAWAL_ID, [CONST.SEARCH.GROUP_BY.CATEGORY]: CONST.SEARCH.GROUP_CUSTOM_COLUMNS.CATEGORY, + [CONST.SEARCH.GROUP_BY.TAG]: CONST.SEARCH.GROUP_CUSTOM_COLUMNS.TAG, + [CONST.SEARCH.GROUP_BY.MONTH]: CONST.SEARCH.GROUP_CUSTOM_COLUMNS.MONTH, }[groupBy]; const defaultCustomColumns = { @@ -3313,6 +3488,8 @@ function getColumnsToShow( [CONST.SEARCH.GROUP_BY.FROM]: CONST.SEARCH.GROUP_DEFAULT_COLUMNS.FROM, [CONST.SEARCH.GROUP_BY.WITHDRAWAL_ID]: CONST.SEARCH.GROUP_DEFAULT_COLUMNS.WITHDRAWAL_ID, [CONST.SEARCH.GROUP_BY.CATEGORY]: CONST.SEARCH.GROUP_DEFAULT_COLUMNS.CATEGORY, + [CONST.SEARCH.GROUP_BY.TAG]: CONST.SEARCH.GROUP_DEFAULT_COLUMNS.TAG, + [CONST.SEARCH.GROUP_BY.MONTH]: CONST.SEARCH.GROUP_DEFAULT_COLUMNS.MONTH, }[groupBy]; const filteredVisibleColumns = visibleColumns.filter((column) => Object.values(customColumns).includes(column as ValueOf)); @@ -3385,6 +3562,40 @@ function getColumnsToShow( return result; } + + if (groupBy === CONST.SEARCH.GROUP_BY.TAG) { + const requiredColumns = new Set([CONST.SEARCH.TABLE_COLUMNS.GROUP_TAG]); + const result: SearchColumnType[] = []; + + for (const col of requiredColumns) { + if (!columnsToShow.includes(col as SearchCustomColumnIds)) { + result.push(col); + } + } + + for (const col of columnsToShow) { + result.push(col); + } + + return result; + } + + if (groupBy === CONST.SEARCH.GROUP_BY.MONTH) { + const requiredColumns = new Set([CONST.SEARCH.TABLE_COLUMNS.GROUP_MONTH]); + const result: SearchColumnType[] = []; + + for (const col of requiredColumns) { + if (!columnsToShow.includes(col as SearchCustomColumnIds)) { + result.push(col); + } + } + + for (const col of columnsToShow) { + result.push(col); + } + + return result; + } } const columns: ColumnVisibility = isExpenseReportView @@ -3668,6 +3879,8 @@ export { isTransactionCardGroupListItemType, isTransactionWithdrawalIDGroupListItemType, isTransactionCategoryGroupListItemType, + isTransactionTagGroupListItemType, + isTransactionMonthGroupListItemType, isSearchResultsEmpty, isTransactionListItemType, isReportActionListItemType, @@ -3703,6 +3916,7 @@ export { getTableMinWidth, getCustomColumns, getCustomColumnDefault, + getToFieldValueForTransaction, isTodoSearch, }; export type {SavedSearchMenuItem, SearchTypeMenuSection, SearchTypeMenuItem, SearchDateModifier, SearchDateModifierLower, SearchKey, ArchivedReportsIDSet}; diff --git a/src/libs/TransactionUtils/index.ts b/src/libs/TransactionUtils/index.ts index dd95075bbb89..afe9ad3479af 100644 --- a/src/libs/TransactionUtils/index.ts +++ b/src/libs/TransactionUtils/index.ts @@ -2588,11 +2588,6 @@ function getAllSortedTransactions(iouReportID?: string): Array, originalTransaction?: OnyxEntry): boolean { - const isAddedToReport = !!transaction?.reportID && transaction.reportID !== CONST.REPORT.SPLIT_REPORT_ID && transaction.reportID !== CONST.REPORT.UNREPORTED_REPORT_ID; - if (isAddedToReport) { - return false; - } - if (!originalTransaction) { return !!transaction?.comment?.originalTransactionID && transaction?.comment?.source === 'split'; } diff --git a/src/libs/actions/App.ts b/src/libs/actions/App.ts index 71685a8b7433..23086c1a38b1 100644 --- a/src/libs/actions/App.ts +++ b/src/libs/actions/App.ts @@ -686,7 +686,7 @@ function clearOnyxAndResetApp(shouldNavigateToHomepage?: boolean) { } if (shouldNavigateToHomepage) { - Navigation.navigate(ROUTES.HOME); + Navigation.navigate(ROUTES.INBOX); } if (preservedUserSession) { diff --git a/src/libs/actions/IOU/index.ts b/src/libs/actions/IOU/index.ts index 67af9e80cdfd..5262fca15145 100644 --- a/src/libs/actions/IOU/index.ts +++ b/src/libs/actions/IOU/index.ts @@ -286,6 +286,7 @@ type InitMoneyRequestParams = { lastSelectedDistanceRates?: OnyxEntry; currentUserPersonalDetails: CurrentUserPersonalDetails; hasOnlyPersonalPolicies: boolean; + draftTransactions: OnyxCollection; }; type MoneyRequestInformation = { @@ -1095,6 +1096,7 @@ function initMoneyRequest({ lastSelectedDistanceRates, currentUserPersonalDetails, hasOnlyPersonalPolicies, + draftTransactions, }: InitMoneyRequestParams) { // Generate a brand new transactionID const newTransactionID = CONST.IOU.OPTIMISTIC_TRANSACTION_ID; @@ -1105,7 +1107,7 @@ function initMoneyRequest({ const created = currentDate || format(new Date(), 'yyyy-MM-dd'); // We remove draft transactions created during multi scanning if there are some - removeDraftTransactions(true); + removeDraftTransactions(true, draftTransactions); // in case we have to re-init money request, but the IOU request type is the same with the old draft transaction, // we should keep most of the existing data by using the ONYX MERGE operation diff --git a/src/libs/actions/MultifactorAuthentication.ts b/src/libs/actions/MultifactorAuthentication.ts index 9be2595b3307..5ff65ac444ec 100644 --- a/src/libs/actions/MultifactorAuthentication.ts +++ b/src/libs/actions/MultifactorAuthentication.ts @@ -1,6 +1,7 @@ /* eslint-disable rulesdir/no-api-side-effects-method */ // These functions use makeRequestWithSideEffects because challenge data must be returned immediately // for security and timing requirements (see detailed explanation below) +import Onyx from 'react-native-onyx'; import type {MultifactorAuthenticationScenarioParameters} from '@components/MultifactorAuthentication/config/types'; import {makeRequestWithSideEffects} from '@libs/API'; import {SIDE_EFFECT_REQUEST_COMMANDS} from '@libs/API/types'; @@ -8,6 +9,7 @@ import Log from '@libs/Log'; import {parseHttpRequest} from '@libs/MultifactorAuthentication/Biometrics/helpers'; import type {ChallengeType} from '@libs/MultifactorAuthentication/Biometrics/types'; import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; /** * To keep the code clean and readable, these functions return parsed data in order to: @@ -70,4 +72,20 @@ async function troubleshootMultifactorAuthentication({signedChallenge}: Multifac } } -export {registerAuthenticationKey, requestAuthenticationChallenge, troubleshootMultifactorAuthentication}; +async function revokeMultifactorAuthenticationCredentials() { + try { + Onyx.merge(ONYXKEYS.ACCOUNT, {isLoading: true}); + const response = await makeRequestWithSideEffects(SIDE_EFFECT_REQUEST_COMMANDS.REVOKE_MULTIFACTOR_AUTHENTICATION_CREDENTIALS, {}); + Onyx.merge(ONYXKEYS.ACCOUNT, {isLoading: false}); + + const {jsonCode, message} = response ?? {}; + + return parseHttpRequest(jsonCode, CONST.MULTIFACTOR_AUTHENTICATION.API_RESPONSE_MAP.REVOKE_MULTIFACTOR_AUTHENTICATION_SETUP, message); + } catch (error) { + Log.hmmm('[MultifactorAuthentication] Failed to revoke multifactor authentication credentials', {error}); + Onyx.merge(ONYXKEYS.ACCOUNT, {isLoading: false}); + return parseHttpRequest(undefined, CONST.MULTIFACTOR_AUTHENTICATION.API_RESPONSE_MAP.REVOKE_MULTIFACTOR_AUTHENTICATION_SETUP, undefined); + } +} + +export {registerAuthenticationKey, requestAuthenticationChallenge, troubleshootMultifactorAuthentication, revokeMultifactorAuthenticationCredentials}; diff --git a/src/libs/actions/Policy/Policy.ts b/src/libs/actions/Policy/Policy.ts index 67ea91c49ac1..6574412751fb 100644 --- a/src/libs/actions/Policy/Policy.ts +++ b/src/libs/actions/Policy/Policy.ts @@ -89,7 +89,7 @@ import * as PolicyUtils from '@libs/PolicyUtils'; import {getCustomUnitsForDuplication, getMemberAccountIDsForWorkspace, goBackWhenEnableFeature, isControlPolicy, navigateToExpensifyCardPage} from '@libs/PolicyUtils'; import * as ReportUtils from '@libs/ReportUtils'; import {hasValidModifiedAmount} from '@libs/TransactionUtils'; -import type {PolicySelector} from '@pages/home/sidebar/FloatingActionButtonAndPopover'; +import type {PolicySelector} from '@pages/inbox/sidebar/FloatingActionButtonAndPopover'; import type {Feature} from '@pages/OnboardingInterestedFeatures/types'; import * as PaymentMethods from '@userActions/PaymentMethods'; import * as PersistedRequests from '@userActions/PersistedRequests'; diff --git a/src/libs/actions/Policy/Rules.ts b/src/libs/actions/Policy/Rules.ts index db75f1082bf0..c53cad1b19e4 100644 --- a/src/libs/actions/Policy/Rules.ts +++ b/src/libs/actions/Policy/Rules.ts @@ -1,7 +1,54 @@ +import Onyx from 'react-native-onyx'; import * as API from '@libs/API'; import type OpenPolicyRulesPageParams from '@libs/API/parameters/OpenPolicyRulesPageParams'; -import {READ_COMMANDS} from '@libs/API/types'; +import type SetPolicyCodingRuleParams from '@libs/API/parameters/SetPolicyCodingRuleParams'; +import {READ_COMMANDS, WRITE_COMMANDS} from '@libs/API/types'; +import * as ErrorUtils from '@libs/ErrorUtils'; import Log from '@libs/Log'; +import * as NumberUtils from '@libs/NumberUtils'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {MerchantRuleForm} from '@src/types/form'; +import type Policy from '@src/types/onyx/Policy'; +import type {CodingRule, CodingRuleTax} from '@src/types/onyx/Policy'; + +/** + * Builds the tax object from a tax key and policy + */ +function buildTaxObject(taxKey: string | undefined, policy: Policy | undefined): CodingRuleTax | undefined { + if (!taxKey || !policy?.taxRates?.taxes) { + return undefined; + } + + const tax = policy.taxRates.taxes[taxKey]; + if (!tax) { + return undefined; + } + + return { + // eslint-disable-next-line @typescript-eslint/naming-convention + field_id_TAX: { + externalID: taxKey, + value: tax.value, + name: tax.name, + }, + }; +} + +/** + * Maps form fields to rule properties + */ +function mapFormFieldsToRule(form: MerchantRuleForm, policy: Policy | undefined) { + return { + merchant: form.merchant || undefined, + category: form.category || undefined, + tag: form.tag || undefined, + tax: buildTaxObject(form.tax, policy), + comment: form.comment || undefined, + reimbursable: form.reimbursable, + billable: form.billable, + }; +} /** * Fetches policy rules data when the rules page is opened. @@ -17,5 +64,189 @@ function openPolicyRulesPage(policyID: string | undefined) { API.read(READ_COMMANDS.OPEN_POLICY_RULES_PAGE, params); } -// eslint-disable-next-line import/prefer-default-export -export {openPolicyRulesPage}; +/** + * Creates or updates a coding rule for the given policy + * @param policyID - The ID of the policy to create/update the rule for + * @param form - The form data for the merchant rule + * @param policy - The policy object (needed to build tax data) + * @param ruleID - Optional existing rule ID for updates + * @param shouldUpdateMatchingTransactions - Whether to update transactions that match the rule + */ +function setPolicyCodingRule(policyID: string, form: MerchantRuleForm, policy: Policy | undefined, ruleID?: string, shouldUpdateMatchingTransactions = false) { + if (!policyID || !form.merchantToMatch) { + Log.warn('Invalid params for setPolicyCodingRule', {policyID, merchantToMatch: form.merchantToMatch}); + return; + } + + const isEditing = !!ruleID; + const existingRule = isEditing ? policy?.rules?.codingRules?.[ruleID] : undefined; + const ruleFields = mapFormFieldsToRule(form, policy); + + // When editing, use the existing rule and merge updated fields; when adding, create a new rule + const targetRuleID = ruleID ?? NumberUtils.rand64(); + const ruleForOptimisticUpdate: CodingRule = + isEditing && existingRule + ? { + ...existingRule, + ...ruleFields, + filters: { + ...existingRule.filters, + right: form.merchantToMatch, + }, + } + : { + ruleID: targetRuleID, + filters: { + left: 'merchant', + operator: 'eq', + right: form.merchantToMatch, + }, + ...ruleFields, + created: new Date().toISOString(), + }; + + const policyKey = `${ONYXKEYS.COLLECTION.POLICY}${policyID}` as const; + const pendingAction = isEditing ? CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE : CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD; + + // On failure: for new rules, remove the optimistic rule; for edits, restore the original rule + const failureRuleValue = isEditing ? existingRule : null; + + const onyxData = { + optimisticData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: policyKey, + value: { + rules: { + codingRules: { + [targetRuleID]: ruleForOptimisticUpdate, + }, + }, + pendingFields: { + rules: pendingAction, + }, + }, + }, + ], + successData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: policyKey, + value: { + pendingFields: { + rules: null, + }, + errorFields: { + rules: null, + }, + }, + }, + ], + failureData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: policyKey, + value: { + rules: { + codingRules: { + [targetRuleID]: failureRuleValue, + }, + }, + pendingFields: { + rules: null, + }, + errorFields: { + rules: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('common.genericErrorMessage'), + }, + }, + }, + ], + }; + + const parameters: SetPolicyCodingRuleParams = { + policyID, + ruleID: targetRuleID, + value: JSON.stringify(ruleForOptimisticUpdate), + shouldUpdateMatchingTransactions, + }; + + API.write(WRITE_COMMANDS.SET_POLICY_CODING_RULE, parameters, onyxData); +} + +/** + * Deletes a coding rule from the given policy + * @param policyID - The ID of the policy to delete the rule from + * @param ruleID - The ID of the rule to delete + */ +function deletePolicyCodingRule(policy: Policy, ruleID: string) { + if (!policy.id || !ruleID) { + Log.warn('Invalid params for deletePolicyCodingRule'); + return; + } + + const policyKey = `${ONYXKEYS.COLLECTION.POLICY}${policy.id}` as const; + const existingRule = policy.rules?.codingRules?.[ruleID]; + + const onyxData = { + optimisticData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: policyKey, + value: { + rules: { + codingRules: { + [ruleID]: null, + }, + }, + pendingFields: { + rules: CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE, + }, + }, + }, + ], + successData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: policyKey, + value: { + pendingFields: { + rules: null, + }, + errorFields: { + rules: null, + }, + }, + }, + ], + failureData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: policyKey, + value: { + rules: { + codingRules: { + [ruleID]: existingRule, + }, + }, + pendingFields: { + rules: null, + }, + errorFields: { + rules: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('common.genericErrorMessage'), + }, + }, + }, + ], + }; + + const parameters: SetPolicyCodingRuleParams = { + policyID: policy.id, + ruleID, + value: '', + shouldUpdateMatchingTransactions: false, + }; + + API.write(WRITE_COMMANDS.SET_POLICY_CODING_RULE, parameters, onyxData); +} + +export {openPolicyRulesPage, setPolicyCodingRule, deletePolicyCodingRule}; diff --git a/src/libs/actions/Report.ts b/src/libs/actions/Report.ts index f9aef5e51f55..fb359ebd85c7 100644 --- a/src/libs/actions/Report.ts +++ b/src/libs/actions/Report.ts @@ -102,7 +102,9 @@ import { import processReportIDDeeplink from '@libs/processReportIDDeeplink'; import Pusher from '@libs/Pusher'; import type {UserIsLeavingRoomEvent, UserIsTypingEvent} from '@libs/Pusher/types'; +import * as ReportActionsFollowupUtils from '@libs/ReportActionsFollowupUtils'; import * as ReportActionsUtils from '@libs/ReportActionsUtils'; +import {getLastVisibleAction} from '@libs/ReportActionsUtils'; import {updateTitleFieldToMatchPolicy} from '@libs/ReportTitleUtils'; import type {Ancestor, OptimisticAddCommentReportAction, OptimisticChatReport, SelfDMParameters} from '@libs/ReportUtils'; import { @@ -532,6 +534,39 @@ function notifyNewAction(reportID: string | undefined, accountID: number | undef actionSubscriber.callback(isFromCurrentUser, reportAction); } +/** + * Builds an optimistic report action with resolved followups (followup-list marked as selected). + * @param reportAction - The report action to check and potentially resolve + * @returns Null if the action doesn't have unresolved followups or the updated report action with resolved followups. + */ +function buildOptimisticResolvedFollowups(reportAction: OnyxEntry): ReportAction | null { + if (!reportAction) { + return null; + } + + const message = ReportActionsUtils.getReportActionMessage(reportAction); + if (!message) { + return null; + } + const html = message?.html ?? ''; + const followups = ReportActionsFollowupUtils.parseFollowupsFromHtml(html); + + if (!followups || followups.length === 0) { + return null; + } + + const updatedHtml = html.replace(/]*)?>/, ''); + return { + ...reportAction, + message: [ + { + ...message, + html: updatedHtml, + }, + ], + }; +} + /** * Add up to two report actions to a report. This method can be called for the following situations: * @@ -597,7 +632,7 @@ function addActions(report: OnyxEntry, notifyReportID: string, ancestors } // Optimistically add the new actions to the store before waiting to save them to the server - const optimisticReportActions: OnyxCollection = {}; + const optimisticReportActions: OnyxCollection = {}; // Only add the reportCommentAction when there is no file attachment. If there is both a file attachment and text, that will all be contained in the attachmentAction. if (text && reportCommentAction?.reportActionID && !file) { @@ -606,6 +641,17 @@ function addActions(report: OnyxEntry, notifyReportID: string, ancestors if (file && attachmentAction?.reportActionID) { optimisticReportActions[attachmentAction.reportActionID] = attachmentAction; } + + // Check if the last visible action is from Concierge with unresolved followups + // If so, optimistically resolve them by adding the updated action to optimisticReportActions + const lastVisibleAction = getLastVisibleAction(reportID); + const lastActorAccountID = lastVisibleAction?.actorAccountID; + const lastActionReportActionID = lastVisibleAction?.reportActionID; + const resolvedAction = buildOptimisticResolvedFollowups(lastVisibleAction); + if (lastActorAccountID === CONST.ACCOUNT_ID.CONCIERGE && lastActionReportActionID && resolvedAction) { + optimisticReportActions[lastActionReportActionID] = resolvedAction; + } + const parameters: AddCommentOrAttachmentParams = { reportID, reportActionID: file ? attachmentAction?.reportActionID : reportCommentAction?.reportActionID, @@ -662,9 +708,7 @@ function addActions(report: OnyxEntry, notifyReportID: string, ancestors }; const {lastMessageText = ''} = ReportActionsUtils.getLastVisibleMessage(reportID); if (lastMessageText) { - const lastVisibleAction = ReportActionsUtils.getLastVisibleAction(reportID); const lastVisibleActionCreated = lastVisibleAction?.created; - const lastActorAccountID = lastVisibleAction?.actorAccountID; failureReport = { lastMessageText, lastVisibleActionCreated, @@ -672,7 +716,7 @@ function addActions(report: OnyxEntry, notifyReportID: string, ancestors }; } - const failureReportActions: Record = {}; + const failureReportActions: Record = {}; for (const [actionKey, action] of Object.entries(optimisticReportActions)) { failureReportActions[actionKey] = { @@ -681,6 +725,10 @@ function addActions(report: OnyxEntry, notifyReportID: string, ancestors errors: getMicroSecondOnyxErrorWithTranslationKey('report.genericAddCommentFailureMessage'), }; } + // In case of error bring back the follow up buttons to the cast comment + if (lastActorAccountID === CONST.ACCOUNT_ID.CONCIERGE && lastActionReportActionID) { + failureReportActions[lastActionReportActionID] = lastVisibleAction; + } const failureData: Array> = [ { @@ -6495,6 +6543,46 @@ function resolveConciergeDescriptionOptions( resolveConciergeOptions(report, notifyReportID, reportActionID, selectedDescription, timezoneParam, 'selectedDescription', ancestors); } +/** + * Resolves a suggested followup by posting the selected question as a comment + * and optimistically updating the HTML to mark the followup-list as resolved. + * @param report - The report where the action exists + * @param notifyReportID - The report ID to notify for new actions + * @param reportAction - The report action containing the followup-list + * @param selectedFollowup - The followup question selected by the user + * @param timezoneParam - The user's timezone + * @param ancestors - Array of ancestor reports for proper threading + */ +function resolveSuggestedFollowup( + report: OnyxEntry, + notifyReportID: string | undefined, + reportAction: OnyxEntry, + selectedFollowup: string, + timezoneParam: Timezone, + ancestors: Ancestor[] = [], +) { + const reportID = report?.reportID; + const reportActionID = reportAction?.reportActionID; + + if (!reportID || !reportActionID) { + return; + } + + const resolvedAction = buildOptimisticResolvedFollowups(reportAction); + + if (!resolvedAction) { + return; + } + + // Optimistically update the HTML to mark followup-list as resolved + Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`, { + [reportActionID]: resolvedAction, + }); + + // Post the selected followup question as a comment + addComment(report, notifyReportID ?? reportID, ancestors, selectedFollowup, timezoneParam); +} + /** * Enhances existing transaction thread reports with additional context for navigation * @@ -6534,6 +6622,7 @@ export { broadcastUserIsLeavingRoom, broadcastUserIsTyping, buildOptimisticChangePolicyData, + buildOptimisticResolvedFollowups, clearAddRoomMemberError, clearAvatarErrors, clearDeleteTransactionNavigateBackUrl, @@ -6596,6 +6685,7 @@ export { resolveActionableReportMentionWhisper, resolveConciergeCategoryOptions, resolveConciergeDescriptionOptions, + resolveSuggestedFollowup, savePrivateNotesDraft, saveReportActionDraft, saveReportDraftComment, diff --git a/src/libs/actions/Session/index.ts b/src/libs/actions/Session/index.ts index da79ff8ea9e6..a8574d79df36 100644 --- a/src/libs/actions/Session/index.ts +++ b/src/libs/actions/Session/index.ts @@ -45,7 +45,7 @@ import * as SessionUtils from '@libs/SessionUtils'; import {checkIfShouldUseNewPartnerName, resetDidUserLogInDuringSession} from '@libs/SessionUtils'; import {clearSoundAssetsCache} from '@libs/Sound'; import Timers from '@libs/Timers'; -import {hideContextMenu} from '@pages/home/report/ContextMenu/ReportActionContextMenu'; +import {hideContextMenu} from '@pages/inbox/report/ContextMenu/ReportActionContextMenu'; import {confirmReadyToOpenApp, KEYS_TO_PRESERVE, openApp} from '@userActions/App'; import {KEYS_TO_PRESERVE_DELEGATE_ACCESS} from '@userActions/Delegate'; import * as Device from '@userActions/Device'; diff --git a/src/libs/actions/User.ts b/src/libs/actions/User.ts index a0e14ff5cbc9..be86c24a880c 100644 --- a/src/libs/actions/User.ts +++ b/src/libs/actions/User.ts @@ -48,7 +48,7 @@ import CONFIG from '@src/CONFIG'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; -import type {ExpenseRuleForm} from '@src/types/form'; +import type {ExpenseRuleForm, MerchantRuleForm} from '@src/types/form'; import type {AppReview, BlockedFromConcierge, CustomStatusDraft, LoginList, Policy} from '@src/types/onyx'; import type Login from '@src/types/onyx/Login'; import type {Errors} from '@src/types/onyx/OnyxCommon'; @@ -1682,6 +1682,18 @@ function clearDraftRule() { Onyx.set(ONYXKEYS.FORMS.EXPENSE_RULE_FORM, null); } +function setDraftMerchantRule(ruleData: Partial) { + Onyx.set(ONYXKEYS.FORMS.MERCHANT_RULE_FORM, ruleData); +} + +function updateDraftMerchantRule(ruleData: Partial) { + Onyx.merge(ONYXKEYS.FORMS.MERCHANT_RULE_FORM, ruleData); +} + +function clearDraftMerchantRule() { + Onyx.set(ONYXKEYS.FORMS.MERCHANT_RULE_FORM, null); +} + export { closeAccount, setServerErrorsOnForm, @@ -1728,4 +1740,7 @@ export { setDraftRule, updateDraftRule, clearDraftRule, + setDraftMerchantRule, + updateDraftMerchantRule, + clearDraftMerchantRule, }; diff --git a/src/libs/calculateAnchorPosition.ts b/src/libs/calculateAnchorPosition.ts index 90766f4582ba..2499be5cc79a 100644 --- a/src/libs/calculateAnchorPosition.ts +++ b/src/libs/calculateAnchorPosition.ts @@ -1,4 +1,4 @@ -import type {ContextMenuAnchor} from '@pages/home/report/ContextMenu/ReportActionContextMenu'; +import type {ContextMenuAnchor} from '@pages/inbox/report/ContextMenu/ReportActionContextMenu'; import CONST from '@src/CONST'; import type {AnchorDimensions, AnchorPosition} from '@src/styles'; import type {AnchorOrigin} from './actions/EmojiPickerAction'; diff --git a/src/libs/telemetry/TelemetrySynchronizer.ts b/src/libs/telemetry/TelemetrySynchronizer.ts index a8de074dd5d2..5780d41e4e2e 100644 --- a/src/libs/telemetry/TelemetrySynchronizer.ts +++ b/src/libs/telemetry/TelemetrySynchronizer.ts @@ -10,6 +10,7 @@ import {getActivePolicies} from '@libs/PolicyUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type {Policy, Session, TryNewDot} from '@src/types/onyx'; +import './sendMemoryContext'; /** * Connect to Onyx to retrieve information about the user's active policies. diff --git a/src/libs/telemetry/getMemoryInfo/index.native.ts b/src/libs/telemetry/getMemoryInfo/index.native.ts new file mode 100644 index 000000000000..afd70d0b6a79 --- /dev/null +++ b/src/libs/telemetry/getMemoryInfo/index.native.ts @@ -0,0 +1,61 @@ +import {Platform} from 'react-native'; +import DeviceInfo from 'react-native-device-info'; +import Log from '@libs/Log'; +import type {MemoryInfo} from './types'; + +const BYTES_PER_MB = 1024 * 1024; + +const normalizeMemoryValue = (value: number | null | undefined): number | null => { + if (value === null || value === undefined || value < 0) { + return null; + } + return value; +}; + +const getMemoryInfo = async (): Promise => { + try { + const totalMemoryBytesRaw = DeviceInfo.getTotalMemorySync?.() ?? null; + const totalMemoryBytes = normalizeMemoryValue(totalMemoryBytesRaw); + + const [usedMemory, maxMemory] = await Promise.allSettled([DeviceInfo.getUsedMemory(), Platform.OS === 'android' ? DeviceInfo.getMaxMemory() : Promise.resolve(null)]); + + const usedMemoryBytesRaw = usedMemory.status === 'fulfilled' ? usedMemory.value : null; + const usedMemoryBytes = normalizeMemoryValue(usedMemoryBytesRaw); + + const maxMemoryBytesRaw = maxMemory.status === 'fulfilled' ? maxMemory.value : null; + const maxMemoryBytes = normalizeMemoryValue(maxMemoryBytesRaw); + + const memoryInfo: MemoryInfo = { + usedMemoryBytes, + usedMemoryMB: usedMemoryBytes !== null ? Math.round(usedMemoryBytes / BYTES_PER_MB) : null, + totalMemoryBytes, + maxMemoryBytes, + usagePercentage: usedMemoryBytes !== null && totalMemoryBytes !== null && totalMemoryBytes > 0 ? parseFloat(((usedMemoryBytes / totalMemoryBytes) * 100).toFixed(2)) : null, + freeMemoryBytes: totalMemoryBytes !== null && usedMemoryBytes !== null ? totalMemoryBytes - usedMemoryBytes : null, + freeMemoryMB: totalMemoryBytes !== null && usedMemoryBytes !== null ? Math.round((totalMemoryBytes - usedMemoryBytes) / BYTES_PER_MB) : null, + freeMemoryPercentage: totalMemoryBytes !== null && usedMemoryBytes !== null ? parseFloat((((totalMemoryBytes - usedMemoryBytes) / totalMemoryBytes) * 100).toFixed(2)) : null, + platform: Platform.OS, + }; + + Log.info(`[getMemoryInfo] Memory check: ${memoryInfo.usedMemoryMB ?? '?'}MB used / ${memoryInfo.freeMemoryMB ?? '?'}MB free`, true, { + ...memoryInfo, + }); + + return memoryInfo; + } catch (error) { + Log.hmmm('[getMemoryInfo] Failed to get memory info', {error}); + return { + usedMemoryBytes: null, + usedMemoryMB: null, + totalMemoryBytes: null, + maxMemoryBytes: null, + usagePercentage: null, + freeMemoryBytes: null, + freeMemoryMB: null, + freeMemoryPercentage: null, + platform: Platform.OS, + }; + } +}; + +export default getMemoryInfo; diff --git a/src/libs/telemetry/getMemoryInfo/index.ts b/src/libs/telemetry/getMemoryInfo/index.ts new file mode 100644 index 000000000000..283d1fdcd9a1 --- /dev/null +++ b/src/libs/telemetry/getMemoryInfo/index.ts @@ -0,0 +1,104 @@ +import {Platform} from 'react-native'; +import Log from '@libs/Log'; +import type {MemoryInfo} from './types'; + +const BYTES_PER_MB = 1024 * 1024; +const BYTES_PER_GB = BYTES_PER_MB * 1024; + +// Only works in Chrome/Edge (Chromium browsers) - navigator.deviceMemory and performance.memory are not available in Firefox/Safari +const getMemoryInfo = async (): Promise => { + try { + let totalMemoryBytes: number | null = null; + let usedMemoryBytes: number | null = null; + let maxMemoryBytes: number | null = null; + + if (typeof window === 'undefined') { + return { + usedMemoryBytes: null, + usedMemoryMB: null, + totalMemoryBytes: null, + maxMemoryBytes: null, + usagePercentage: null, + freeMemoryBytes: null, + freeMemoryMB: null, + freeMemoryPercentage: null, + platform: Platform.OS, + }; + } + + if (window.navigator) { + try { + const deviceMemoryGB = (window.navigator as Navigator & {deviceMemory?: number}).deviceMemory; + if (deviceMemoryGB && deviceMemoryGB > 0) { + totalMemoryBytes = deviceMemoryGB * BYTES_PER_GB; + } + } catch (error) { + // Gracefully degrade - deviceMemory requires HTTPS and is Chromium-only + } + } + + // performance.memory is deprecated but still works in Chromium, not enumerable so we use direct access + if (window.performance) { + try { + const perfMemory = ( + window.performance as Performance & { + memory?: { + usedJSHeapSize?: number; + totalJSHeapSize?: number; + jsHeapSizeLimit?: number; + }; + } + ).memory; + + if (perfMemory) { + if (perfMemory.usedJSHeapSize && perfMemory.usedJSHeapSize > 0) { + usedMemoryBytes = perfMemory.usedJSHeapSize; + } + + if (perfMemory.jsHeapSizeLimit && perfMemory.jsHeapSizeLimit > 0) { + maxMemoryBytes = perfMemory.jsHeapSizeLimit; + } + + if (!totalMemoryBytes && perfMemory.totalJSHeapSize && perfMemory.totalJSHeapSize > 0) { + totalMemoryBytes = perfMemory.totalJSHeapSize; + } + } + } catch (error) { + // Gracefully degrade - these APIs are not available in Firefox/Safari + } + } + + const memoryInfo: MemoryInfo = { + usedMemoryBytes, + usedMemoryMB: usedMemoryBytes !== null ? Math.round(usedMemoryBytes / BYTES_PER_MB) : null, + totalMemoryBytes, + maxMemoryBytes, + usagePercentage: usedMemoryBytes !== null && totalMemoryBytes !== null && totalMemoryBytes > 0 ? parseFloat(((usedMemoryBytes / totalMemoryBytes) * 100).toFixed(2)) : null, + freeMemoryBytes: totalMemoryBytes !== null && usedMemoryBytes !== null ? totalMemoryBytes - usedMemoryBytes : null, + freeMemoryMB: totalMemoryBytes !== null && usedMemoryBytes !== null ? Math.round((totalMemoryBytes - usedMemoryBytes) / BYTES_PER_MB) : null, + freeMemoryPercentage: totalMemoryBytes !== null && usedMemoryBytes !== null ? parseFloat((((totalMemoryBytes - usedMemoryBytes) / totalMemoryBytes) * 100).toFixed(2)) : null, + platform: Platform.OS, + }; + + Log.info(`[getMemoryInfo] Memory check: ${memoryInfo.usedMemoryMB ?? '?'}MB used / ${memoryInfo.freeMemoryMB ?? '?'}MB free`, true, { + ...memoryInfo, + }); + + return memoryInfo; + } catch (error) { + Log.hmmm('[getMemoryInfo] Failed to get memory info', {error}); + return { + usedMemoryBytes: null, + usedMemoryMB: null, + totalMemoryBytes: null, + maxMemoryBytes: null, + usagePercentage: null, + freeMemoryBytes: null, + freeMemoryMB: null, + freeMemoryPercentage: null, + platform: Platform.OS, + }; + } +}; + +export default getMemoryInfo; diff --git a/src/libs/telemetry/getMemoryInfo/types.ts b/src/libs/telemetry/getMemoryInfo/types.ts new file mode 100644 index 000000000000..0f6bfe49b357 --- /dev/null +++ b/src/libs/telemetry/getMemoryInfo/types.ts @@ -0,0 +1,14 @@ +type MemoryInfo = { + usedMemoryBytes: number | null; + usedMemoryMB: number | null; + totalMemoryBytes: number | null; + maxMemoryBytes: number | null; + usagePercentage: number | null; + freeMemoryBytes: number | null; + freeMemoryMB: number | null; + freeMemoryPercentage: number | null; + platform: string; +}; + +// eslint-disable-next-line import/prefer-default-export +export type {MemoryInfo}; diff --git a/src/libs/telemetry/middlewares/scopeTagsEnricher.ts b/src/libs/telemetry/middlewares/scopeTagsEnricher.ts index 4edfb1227079..09f823b5ccc2 100644 --- a/src/libs/telemetry/middlewares/scopeTagsEnricher.ts +++ b/src/libs/telemetry/middlewares/scopeTagsEnricher.ts @@ -29,6 +29,9 @@ const scopeTagsEnricher: TelemetryBeforeSend = (event: TransactionEvent): Transa ...(scopeData.contexts?.[CONST.TELEMETRY.CONTEXT_POLICIES] && { [CONST.TELEMETRY.CONTEXT_POLICIES]: scopeData.contexts[CONST.TELEMETRY.CONTEXT_POLICIES], }), + ...(scopeData.contexts?.[CONST.TELEMETRY.CONTEXT_MEMORY] && { + [CONST.TELEMETRY.CONTEXT_MEMORY]: scopeData.contexts[CONST.TELEMETRY.CONTEXT_MEMORY], + }), }, }; diff --git a/src/libs/telemetry/sendMemoryContext.ts b/src/libs/telemetry/sendMemoryContext.ts new file mode 100644 index 000000000000..6bef879438e8 --- /dev/null +++ b/src/libs/telemetry/sendMemoryContext.ts @@ -0,0 +1,94 @@ +import * as Sentry from '@sentry/react-native'; +import AppStateMonitor from '@libs/AppStateMonitor'; +import Log from '@libs/Log'; +import CONST from '@src/CONST'; +import getMemoryInfo from './getMemoryInfo'; + +let memoryTrackingIntervalID: ReturnType | undefined; +let memoryTrackingListenerCleanup: (() => void) | undefined; + +/** + * Send memory usage context to Sentry + * Called on app start, when app comes to foreground, and periodically every 2 minutes + */ +function sendMemoryContext() { + getMemoryInfo() + .then((memoryInfo) => { + const freeMemoryMB = memoryInfo.freeMemoryMB; + const usedMemoryMB = memoryInfo.usedMemoryMB; + let logLevel: Sentry.SeverityLevel = 'info'; + /** + * Log Level Thresholds (Based on OS resource management): + * * 1. < 50MB (Error): Critical memory exhaustion. The OS's memory killer + * (Jetsam on iOS / Low Memory Killer on Android) is likely to terminate the process immediately. + * * 2. < 120MB (Warning): System starts sending 'didReceiveMemoryWarning' signals. + * The app is unstable and any sudden allocation spike will lead to a crash. + * * 3. > 120MB (Info): Safe operational zone for most modern mobile devices. + */ + if (freeMemoryMB !== null) { + if (freeMemoryMB < CONST.TELEMETRY.CONFIG.MEMORY_THRESHOLD_CRITICAL) { + logLevel = 'error'; + } else if (freeMemoryMB < CONST.TELEMETRY.CONFIG.MEMORY_THRESHOLD_WARNING) { + logLevel = 'warning'; + } + } else if (memoryInfo.usagePercentage && memoryInfo.usagePercentage > CONST.TELEMETRY.CONFIG.MEMORY_THRESHOLD_CRITICAL_PERCENTAGE) { + logLevel = 'error'; + } + + Sentry.addBreadcrumb({ + category: 'system.memory', + message: `RAM Check: ${usedMemoryMB ?? '?'}MB used / ${freeMemoryMB ?? '?'}MB free`, + level: logLevel, + data: { + ...memoryInfo, + freeMemoryMB, + usedMemoryMB, + }, + }); + + Sentry.setContext(CONST.TELEMETRY.CONTEXT_MEMORY, { + ...memoryInfo, + freeMemoryMB, + lowMemoryThreat: logLevel !== 'info', + lastUpdated: new Date().toISOString(), + }); + }) + .catch((error) => { + Log.hmmm('[SentrySync] Failed to get memory info', { + error, + }); + }); +} + +function initializeMemoryTracking() { + if (memoryTrackingIntervalID) { + clearInterval(memoryTrackingIntervalID); + memoryTrackingIntervalID = undefined; + } + + if (memoryTrackingListenerCleanup) { + memoryTrackingListenerCleanup(); + memoryTrackingListenerCleanup = undefined; + } + + sendMemoryContext(); + memoryTrackingListenerCleanup = AppStateMonitor.addBecameActiveListener(sendMemoryContext); + memoryTrackingIntervalID = setInterval(sendMemoryContext, CONST.TELEMETRY.CONFIG.MEMORY_TRACKING_INTERVAL); +} + +initializeMemoryTracking(); + +function cleanupMemoryTracking() { + if (memoryTrackingIntervalID) { + clearInterval(memoryTrackingIntervalID); + memoryTrackingIntervalID = undefined; + } + + if (memoryTrackingListenerCleanup) { + memoryTrackingListenerCleanup(); + memoryTrackingListenerCleanup = undefined; + } +} + +export {cleanupMemoryTracking, initializeMemoryTracking}; +export default sendMemoryContext; diff --git a/src/pages/ConciergePage.tsx b/src/pages/ConciergePage.tsx index 5bdd29afeb1f..2c0bbf9dece7 100644 --- a/src/pages/ConciergePage.tsx +++ b/src/pages/ConciergePage.tsx @@ -36,7 +36,7 @@ function ConciergePage() { navigateToConciergeChat(conciergeReportID, true, () => !isUnmounted.current); }); } else { - Navigation.navigate(ROUTES.HOME); + Navigation.navigate(ROUTES.INBOX); } }, [session, isLoadingReportData, conciergeReportID]), ); diff --git a/src/pages/Debug/ReportAction/DebugReportActionCreatePage.tsx b/src/pages/Debug/ReportAction/DebugReportActionCreatePage.tsx index 3f1ed6478ea5..2b64029efad6 100644 --- a/src/pages/Debug/ReportAction/DebugReportActionCreatePage.tsx +++ b/src/pages/Debug/ReportAction/DebugReportActionCreatePage.tsx @@ -19,7 +19,7 @@ import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; import type {DebugParamList} from '@libs/Navigation/types'; import {rand64} from '@libs/NumberUtils'; -import ReportActionItem from '@pages/home/report/ReportActionItem'; +import ReportActionItem from '@pages/inbox/report/ReportActionItem'; import Debug from '@userActions/Debug'; import CONST from '@src/CONST'; import type {TranslationPaths} from '@src/languages/types'; diff --git a/src/pages/Debug/ReportAction/DebugReportActionPreview.tsx b/src/pages/Debug/ReportAction/DebugReportActionPreview.tsx index b068b73fb928..a4a01140cb3e 100644 --- a/src/pages/Debug/ReportAction/DebugReportActionPreview.tsx +++ b/src/pages/Debug/ReportAction/DebugReportActionPreview.tsx @@ -5,7 +5,7 @@ import type {OnyxEntry} from 'react-native-onyx'; import {usePersonalDetails} from '@components/OnyxListItemProvider'; import ScrollView from '@components/ScrollView'; import useOnyx from '@hooks/useOnyx'; -import ReportActionItem from '@pages/home/report/ReportActionItem'; +import ReportActionItem from '@pages/inbox/report/ReportActionItem'; import ONYXKEYS from '@src/ONYXKEYS'; import type {Report, ReportAction} from '@src/types/onyx'; diff --git a/src/pages/FlagCommentPage.tsx b/src/pages/FlagCommentPage.tsx index 8d163c684309..e5cedc052bd0 100644 --- a/src/pages/FlagCommentPage.tsx +++ b/src/pages/FlagCommentPage.tsx @@ -23,8 +23,8 @@ import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type SCREENS from '@src/SCREENS'; import type IconAsset from '@src/types/utils/IconAsset'; -import withReportAndReportActionOrNotFound from './home/report/withReportAndReportActionOrNotFound'; -import type {WithReportAndReportActionOrNotFoundProps} from './home/report/withReportAndReportActionOrNotFound'; +import withReportAndReportActionOrNotFound from './inbox/report/withReportAndReportActionOrNotFound'; +import type {WithReportAndReportActionOrNotFoundProps} from './inbox/report/withReportAndReportActionOrNotFound'; type FlagCommentPageNavigationProps = PlatformStackScreenProps; diff --git a/src/pages/HomePage.tsx b/src/pages/HomePage.tsx new file mode 100644 index 000000000000..b388ea37bdc6 --- /dev/null +++ b/src/pages/HomePage.tsx @@ -0,0 +1,38 @@ +import React from 'react'; +import NavigationTabBar from '@components/Navigation/NavigationTabBar'; +import NAVIGATION_TABS from '@components/Navigation/NavigationTabBar/NAVIGATION_TABS'; +import TopBar from '@components/Navigation/TopBar'; +import ScreenWrapper from '@components/ScreenWrapper'; +import useLocalize from '@hooks/useLocalize'; +import useResponsiveLayout from '@hooks/useResponsiveLayout'; + +function HomePage() { + const {shouldUseNarrowLayout} = useResponsiveLayout(); + const shouldDisplayLHB = !shouldUseNarrowLayout; + const {translate} = useLocalize(); + + return ( + + ) + } + > + + {shouldDisplayLHB && } + + ); +} + +export default HomePage; diff --git a/src/pages/InviteReportParticipantsPage.tsx b/src/pages/InviteReportParticipantsPage.tsx index 16613f65bef0..b230b85ace9c 100644 --- a/src/pages/InviteReportParticipantsPage.tsx +++ b/src/pages/InviteReportParticipantsPage.tsx @@ -32,8 +32,8 @@ import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import type SCREENS from '@src/SCREENS'; import type {InvitedEmailsToAccountIDs} from '@src/types/onyx'; -import type {WithReportOrNotFoundProps} from './home/report/withReportOrNotFound'; -import withReportOrNotFound from './home/report/withReportOrNotFound'; +import type {WithReportOrNotFoundProps} from './inbox/report/withReportOrNotFound'; +import withReportOrNotFound from './inbox/report/withReportOrNotFound'; type InviteReportParticipantsPageProps = WithReportOrNotFoundProps & WithNavigationTransitionEndProps; diff --git a/src/pages/LogInWithShortLivedAuthTokenPage.tsx b/src/pages/LogInWithShortLivedAuthTokenPage.tsx index fb3c9224abe6..2289dd4e1fb2 100644 --- a/src/pages/LogInWithShortLivedAuthTokenPage.tsx +++ b/src/pages/LogInWithShortLivedAuthTokenPage.tsx @@ -34,7 +34,7 @@ function LogInWithShortLivedAuthTokenPage({route}: LogInWithShortLivedAuthTokenP Navigation.isNavigationReady().then(() => { // We must call goBack() to remove the /transition route from history Navigation.goBack(); - Navigation.navigate(ROUTES.HOME); + Navigation.navigate(ROUTES.INBOX); }); return; } diff --git a/src/pages/LogOutPreviousUserPage.tsx b/src/pages/LogOutPreviousUserPage.tsx index 4b4b56d1e26c..fa5342042deb 100644 --- a/src/pages/LogOutPreviousUserPage.tsx +++ b/src/pages/LogOutPreviousUserPage.tsx @@ -44,7 +44,7 @@ function LogOutPreviousUserPage({route}: LogOutPreviousUserPageProps) { Navigation.isNavigationReady().then(() => { // We must call goBack() to remove the /transition route from history Navigation.goBack(); - Navigation.navigate(ROUTES.HOME); + Navigation.navigate(ROUTES.INBOX); }); return; } diff --git a/src/pages/MultifactorAuthentication/RevokePage.tsx b/src/pages/MultifactorAuthentication/RevokePage.tsx new file mode 100644 index 000000000000..ed1bb3ccfa52 --- /dev/null +++ b/src/pages/MultifactorAuthentication/RevokePage.tsx @@ -0,0 +1,113 @@ +import React, {useState} from 'react'; +import {View} from 'react-native'; +import type {OnyxEntry} from 'react-native-onyx'; +import FullPageOfflineBlockingView from '@components/BlockingViews/FullPageOfflineBlockingView'; +import Button from '@components/Button'; +import ConfirmModal from '@components/ConfirmModal'; +import FormHelpMessage from '@components/FormHelpMessage'; +import HeaderWithBackButton from '@components/HeaderWithBackButton'; +import ScreenWrapper from '@components/ScreenWrapper'; +import Text from '@components/Text'; +import useLocalize from '@hooks/useLocalize'; +import useOnyx from '@hooks/useOnyx'; +import useThemeStyles from '@hooks/useThemeStyles'; +import {revokeMultifactorAuthenticationCredentials} from '@libs/actions/MultifactorAuthentication'; +import Navigation from '@libs/Navigation/Navigation'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {Account} from '@src/types/onyx'; + +function getHasDevices(data: OnyxEntry) { + return data?.multifactorAuthenticationPublicKeyIDs && data.multifactorAuthenticationPublicKeyIDs.length > 0; +} + +function getIsLoading(data: OnyxEntry) { + return !!data?.isLoading; +} +function MultifactorAuthenticationRevokePage() { + const {translate} = useLocalize(); + const styles = useThemeStyles(); + const [isConfirmModalVisible, setConfirmModalVisibility] = useState(false); + const [errorMessage, setErrorMessage] = useState(); + + const [hasDevices] = useOnyx(ONYXKEYS.ACCOUNT, {selector: getHasDevices, canBeMissing: true}); + const [isLoading] = useOnyx(ONYXKEYS.ACCOUNT, {selector: getIsLoading, canBeMissing: true}); + + const onGoBackPress = () => { + Navigation.goBack(); + }; + + const showConfirmModal = () => { + setConfirmModalVisibility(true); + }; + + const hideConfirmModal = () => { + setConfirmModalVisibility(false); + }; + + const handleRevokeConfirm = async () => { + const result = await revokeMultifactorAuthenticationCredentials(); + + hideConfirmModal(); + if (result.httpCode !== 200) { + setErrorMessage(translate('multifactorAuthentication.revoke.error')); + } + }; + + return ( + + + + + + {translate(hasDevices ? 'multifactorAuthentication.revoke.explanation' : 'multifactorAuthentication.revoke.noDevices')} + + + {!!errorMessage && ( + + )} + + {hasDevices ? ( +