From 6e131d082b462ce6ba8b9083f66a746c57f8614b Mon Sep 17 00:00:00 2001 From: Ren Jones <153645623+ren-jones@users.noreply.github.com> Date: Fri, 30 Aug 2024 12:21:06 -0500 Subject: [PATCH 001/631] Update and rename Additional-Travel-Integrations.md to Travel-receipt-integrations.md Article updates to include content from community --- .../Additional-Travel-Integrations.md | 73 ----------- .../Travel-receipt-integrations.md | 121 ++++++++++++++++++ 2 files changed, 121 insertions(+), 73 deletions(-) delete mode 100644 docs/articles/expensify-classic/connections/Additional-Travel-Integrations.md create mode 100644 docs/articles/expensify-classic/connections/Travel-receipt-integrations.md diff --git a/docs/articles/expensify-classic/connections/Additional-Travel-Integrations.md b/docs/articles/expensify-classic/connections/Additional-Travel-Integrations.md deleted file mode 100644 index 7dcc8e5e9c29..000000000000 --- a/docs/articles/expensify-classic/connections/Additional-Travel-Integrations.md +++ /dev/null @@ -1,73 +0,0 @@ ---- -title: Importing Receipts from Various Platforms to Expensify -description: Detailed guide on how to import receipts from multiple travel platforms into Expensify. ---- - -# Overview -You can automatically import receipts from many travel platforms into Expensify, to make tracking expenses while traveling for business a breeze. Read on to learn how to import receipts from Bolt Work, Spot Hero, Trainline, Grab, HotelTonight, and Kayak for Business. - -## How to Connect to Bolt Work - -### Set Up Bolt Work Profile -- Open the Bolt app, go to the side navigation menu, and select Payment. -- At the bottom, select Set up work profile and follow the instructions, entering your work email for verification. - -### Link to Expensify -- In the Bolt app, go to Work Rides. -- Select Add expense provider, choose Expensify, and enter the associated email to receive a verification link. -- Ensure you select your work ride profile as the payment method before booking. - -## How to Connect to SpotHero - -### Set up a Business Profile -- Open the SpotHero app, click the hamburger icon, and go to Account Settings. -- Click Set up Business Profile. -- Specify the email connected to Expensify and set up your payment method. -- Upon checkout, choose between Business and Personal Profiles in the "Payment Details" section. -- If you want, you can set a weekly or monthly cadence for consolidated SpotHero expense reports in your Business Profile settings. This will batch all of your SpotHero expenses to import into Expensify at that cadence. - -## How to Connect to Trainline -- To send a ticket receipt to Expensify: - - In the Trainline app, navigate to the My Tickets tab. - - Tap Manage my booking > Expense receipt > Send to Expensify. -- That’s it! - -## How to Connect to Grab -- In the Grab app, tap on your name, go to “Profiles”, and “Add a business profile”. -- Follow instructions and enter your work email for verification. -- In your profile, tap on Business > Expense Solution > Expensify > Save. -- Before booking, select your Business profile and confirm. - -## How to Connect to HotelTonight -- In HotelTonight, go to the Bookings tab and select your booking. -- Select Receipt > Expensify, enter your Expensify email, and send. - -## How to Connect to Kayak for Business - -### Admin Setup -- Admins should go to “Company Settings” and click on “Connect to Expensify”. -- Bookings made by employees will automatically be sent to Expensify. - -### Traveler Setup -- From your account settings, choose whether expenses should be sent to Expensify automatically or manually. -- We recommend sending them automatically, so you can travel without even thinking about your expense reports. - -{% include faq-begin.md %} - -**Q: What if I don’t have the option for Send to Expensify in Trainline?** - -A: This can happen if the native iOS Mail app is not installed on an Apple device. However, you can still use the native iOS share to Expensify function for Trainline receipts. - -**Q: Why should I choose automatic mode in Kayak for Business?** - -A: Automatic mode is less effort as it’s easier to delete an expense in Expensify than to remember to forward a forgotten receipt. - -**Q: Can I receive consolidated reports from SpotHero?** - -A: Yes, you can set a weekly or monthly cadence for SpotHero expenses to be emailed in a consolidated report. - -**Q: Do I need to select a specific profile before booking in Bolt Work and Grab?** - -A: Yes, ensure you have selected your work or business profile as the payment method before booking. - -{% include faq-end.md %} diff --git a/docs/articles/expensify-classic/connections/Travel-receipt-integrations.md b/docs/articles/expensify-classic/connections/Travel-receipt-integrations.md new file mode 100644 index 000000000000..bf0d7e05997d --- /dev/null +++ b/docs/articles/expensify-classic/connections/Travel-receipt-integrations.md @@ -0,0 +1,121 @@ +--- +title: Travel Receipt Integrations +description: How to use pre-built or custom integrations to track travel expenses +--- + +Expensify’s receipt integrations allow a merchant to upload receipts directly to a user’s Expensify account. A merchant just has to email a receipt to an Expensify user and Cc receipts@expensify.com. This automatically creates a transaction in the Expensify account for the user whose email address is in the To field. + +You can set up a receipt integration by using one of our existing pre-built integrations, or by building your own receipt integration. + +## Use a pre-built travel integration + +You can use our pre-built integrations to automatically import travel receipts from Bolt Work, Spot Hero, Grab, and Kayak for Business. + +### Bolt Work + +1. In the Bolt app, tap the menu icon in the top left and tap **Work trips**. +2. Tap **Create profile**. +3. Enter the email address that you use for Expensify, then tap **Next**. +4. Enter your company details, then tap **Next**. +5. Choose a payment method. If you don’t want to use the existing payment methods, you can create a new one by tapping **Add Payment Method**. Then tap **Next**. +6. Tap **Done**. +7. Tap Add expense provider, then tap **Expensify**. +8. Tap **Verify**. +9. Tap the menu icon on the top left and tap **Work trips** once more. +10. Tap **Add expense provider** and select **Expensify** again. + +When booking a trip with Bolt Work, select your work trip profile as the payment method before booking. Then the receipt details will be automatically sent to Expensify. + +### SpotHero + +1. In the SpotHero app, tap the menu icon in the top left and tap **Account Settings**. +2. Tap **Set up Business Profile**. +3. Tap **Create Business Profile**. +4. Enter the email address you use for Expensify and tap **Next**. +5. Tap **Add a Payment Method** and enter your payment account details. Then tap **Next**. +6. Tap **Expensify**. + +When reserving parking with SpotHero, select your business profile in the Payment Details section. Then the receipt will be automatically sent to Expensify. In your SpotHero Business Profile settings, you can also set a weekly or monthly cadence for SpotHero to send a batch of expenses to Expensify. + +### Grab + +1. In the Grab app, tap your profile picture in the top left. +2. Tap your user icon again at the top of the settings menu. +3. Tap **Add a business profile**. +4. Tap Next twice, then tap **Let’s Get Started**. +5. Enter the email address you use for Expensify and tap the next arrow in the bottom right. +6. Check your email and copy the verification code you receive from Grab. +7. Tap **Manage My Business Profile**. +8. Under Preferences, tap **Expense Solution**. +9. Tap **Expensify**, then tap **Save**. + +When booking a trip with Grab, tap **personal** and select **business** to ensure your business profile is selected. Then the receipt will be automatically sent to Expensify. + +### KAYAK for Business + +**Admin Setup** + +This process must be completed by a KAYAK for Business admin. + +1. On your KAYAK for Business homepage, click **Company Settings**. +2. Click **Connect to Expensify**. + +KAYAK for Business will now forward bookings made by each employee into Expensify. + +**Traveler Setup** + +1. On your KAYAK for Business homepage, click **Profile Account Settings**. +2. Enable the Expensify toggle to have your expenses automatically sent to Expensify. You also have the option to send them manually. + +## Build your own receipt integration + +1. Email receiptintegration@expensify.com and include: + - **Subject**: Use “Receipt Integration Request" as the subject line + - **Body**: List all email addresses the merchant sends email receipts from +2. Once you receive your email confirmation (within approximately 2 weeks) that the email addresses have been whitelisted, you’ll then be able to Cc “receipts@expensify.com” on receipt emails to users, and transactions will be created in the users’ Expensify account. +3. Test the integration by sending a receipt email to the email address you used to create your Expensify account and Cc “receipts@expensify.com”. Wait for the receipt to be SmartScanned. Then you will see the merchant, date, and amount added to the transaction. + +### Using the integration + +When sending an emailed receipt: + +- Attachments on an email (that are not an .ics file) will be SmartScanned. We recommend including the receipt as the only attachment. +- You can only include one email address in the To field. In the Cc field, include only receipts@expensify.com. +- Reservations for hotels and car rentals cannot be sent to Expensify as an expense because they are paid at the end of usage. You can only send transaction data for purchases that have already been made. +- Use standardized three-letter currency codes (ISO 4217) where applicable. + +{% include faq-begin.md %} + +**In Trainline, what if I don’t have the option for Send to Expensify?** + +This can happen if the native iOS Mail app is not installed on an Apple device. However, you can still use the native iOS Share to Expensify function for Trainline receipts. + +**Why does it take 2 weeks to set up a custom integration?** + +Receipt integrations require our engineers to manually set them up on the backend. For that reason, it can take up to 2 weeks to set it up. + +**Is there a way to connect via API?** + +No, at this time there are no API receipt integrations. All receipt integrations are managed via receipt emails. + +**What is your Open API?** + +Our Open API is a self-serve tool meant to pull information out of Expensify. Typically, this tool is used to build integrations with accounting solutions that we don’t directly integrate with. If you wish to push data into Expensify, the only way to integrate is via the receipt integration options listed above in this article. + +**Are you able to split one email into separate receipts?** + +The receipt integration is unable to automatically split one email into separate receipts. However, once the receipt is SmartScanned, users can [split the expense](https://help.expensify.com/articles/expensify-classic/expenses/Split-an-expense) in their Expensify account. + +**Can we set up a (co-marketing) partnership?** + +We currently do not offer any co-marketing partnerships. + +**Can we announce or advertise our custom integration with Expensify?** + +Absolutely! You can promote the integration across your social media channels (tag @expensify and use the #expensify hashtag) and you can even create your own dedicated landing page on your website for your integration. At a minimum, we recommend including a brief overview of how the integration works, the benefits of using it, an integration setup guide, and guidance for how someone can contact you for support or integration setup if necessary. + +**How can I get help?** + +You can contact Concierge for ongoing support any time by clicking the green chat icon in the mobile or web app, or by emailing concierge@expensify.com. Concierge is a global team of highly trained product specialists focused on making our product as easy to use as possible and answering all your questions. + +{% include faq-end.md %} From e141c3e675eef6bea861894fc77e385550d971ed Mon Sep 17 00:00:00 2001 From: Ren Jones <153645623+ren-jones@users.noreply.github.com> Date: Fri, 30 Aug 2024 12:24:41 -0500 Subject: [PATCH 002/631] Update redirects.csv Updated to include renaming of article --- docs/redirects.csv | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/redirects.csv b/docs/redirects.csv index 480fd4220bd4..b06f017b6abd 100644 --- a/docs/redirects.csv +++ b/docs/redirects.csv @@ -569,3 +569,4 @@ https://community.expensify.com/home/leaving?allowTrusted=1&target=https%3A%2F%2 https://community.expensify.com/discussion/5654/deep-dive-using-expense-rules-to-vendor-match-when-exporting-to-an-accounting-package/p1?new=1,https://help.expensify.com/articles/expensify-classic/connections/xero/Xero-Troubleshooting https://help.expensify.com/articles/expensify-classic/spending-insights/(https://help.expensify.com/articles/expensify-classic/spending-insights/Custom-Templates),https://help.expensify.com/articles/expensify-classic/spending-insights/Custom-Templates https://help.expensify.com/articles/expensify-classic/settings/account-settings/Set-notifications,https://help.expensify.com/articles/expensify-classic/settings/account-settings/Set-Notifications +https://help.expensify.com/articles/expensify-classic/connections/Additional-Travel-Integrations,https://help.expensify.com/articles/expensify-classic/connections/Travel-receipt-integrations From 54250820933bb1747ba744bc827c31876e65968f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hanno=20J=2E=20G=C3=B6decke?= Date: Thu, 5 Sep 2024 13:27:56 +0200 Subject: [PATCH 003/631] suffix tree impl --- src/libs/SuffixUkkonenTree.ts | 234 ++++++++++++++++++++++++++++++++++ 1 file changed, 234 insertions(+) create mode 100644 src/libs/SuffixUkkonenTree.ts diff --git a/src/libs/SuffixUkkonenTree.ts b/src/libs/SuffixUkkonenTree.ts new file mode 100644 index 000000000000..7599ab2a25f3 --- /dev/null +++ b/src/libs/SuffixUkkonenTree.ts @@ -0,0 +1,234 @@ +import enEmojis from '@assets/emojis/en'; +import {DATA} from './test'; + +const CHAR_CODE_A = 'a'.charCodeAt(0); +const ALPHABET_SIZE = 28; +const DELIMITER_CHAR_CODE = ALPHABET_SIZE - 2; + +function stringToArray(input: string) { + const res: number[] = []; + for (let i = 0; i < input.length; i++) { + const charCode = input.charCodeAt(i) - CHAR_CODE_A; + if (charCode >= 0 && charCode < ALPHABET_SIZE) { + res.push(charCode); + } + } + return res; +} + +function makeTree(a: number[]) { + const N = 1000000; + const t = Array.from({length: N}, () => Array(ALPHABET_SIZE).fill(-1)) as number[][]; + const l = Array(N).fill(0) as number[]; + const r = Array(N).fill(0) as number[]; + const p = Array(N).fill(0) as number[]; + const s = Array(N).fill(0) as number[]; + + let tv = 0; + let tp = 0; + let ts = 2; + let la = 0; + + function initializeTree() { + r.fill(a.length - 1); + s[0] = 1; + l[0] = -1; + r[0] = -1; + l[1] = -1; + r[1] = -1; + t[1].fill(0); + } + + function processCharacter(c: number) { + while (true) { + if (r[tv] < tp) { + if (t[tv][c] === -1) { + createNewLeaf(c); + continue; + } + tv = t[tv][c]; + tp = l[tv]; + } + if (tp === -1 || c === a[tp]) { + tp++; + } else { + splitEdge(c); + continue; + } + break; + } + if (c === DELIMITER_CHAR_CODE) { + resetTreeTraversal(); + } + } + + function createNewLeaf(c: number) { + t[tv][c] = ts; + l[ts] = la; + p[ts++] = tv; + tv = s[tv]; + tp = r[tv] + 1; + } + + function splitEdge(c: number) { + l[ts] = l[tv]; + r[ts] = tp - 1; + p[ts] = p[tv]; + t[ts][a[tp]] = tv; + t[ts][c] = ts + 1; + l[ts + 1] = la; + p[ts + 1] = ts; + l[tv] = tp; + p[tv] = ts; + t[p[ts]][a[l[ts]]] = ts; + ts += 2; + handleDescent(ts); + } + + function handleDescent(ts: number) { + tv = s[p[ts - 2]]; + tp = l[ts - 2]; + while (tp <= r[ts - 2]) { + tv = t[tv][a[tp]]; + tp += r[tv] - l[tv] + 1; + } + if (tp === r[ts - 2] + 1) { + s[ts - 2] = tv; + } else { + s[ts - 2] = ts; + } + tp = r[tv] - (tp - r[ts - 2]) + 2; + } + + function resetTreeTraversal() { + tv = 0; + tp = 0; + } + + function build() { + initializeTree(); + for (la = 0; la < a.length; ++la) { + const c = a[la]; + processCharacter(c); + } + } + + function findSubstring(sString: string) { + const s = stringToArray(sString); + const occurrences: number[] = []; + const st: Array<[number, number]> = [[0, 0]]; + + while (st.length > 0) { + const [node, depth] = st.pop()!; + + let isLeaf = true; + const leftRange = l[node]; + const rightRange = r[node]; + const rangeLen = node === 0 ? 0 : rightRange - leftRange + 1; + + let matches = true; + for (let i = 0; i < rangeLen && depth + i < s.length; i++) { + if (s[depth + i] !== a[leftRange + i]) { + matches = false; + break; + } + } + + if (!matches) { + continue; + } + + for (let i = ALPHABET_SIZE - 1; i >= 0; --i) { + if (t[node][i] !== -1) { + isLeaf = false; + st.push([t[node][i], depth + rangeLen]); + } + } + + if (isLeaf && depth + rangeLen >= s.length) { + occurrences.push(a.length - (depth + rangeLen)); + } + } + + return occurrences; + } + + function findSubstringRecursive(s: string) { + const occurrences: number[] = []; + + function dfs(node: number, depth: number) { + const leftRange = l[node]; + const rightRange = r[node]; + const rangeLen = node === 0 ? 0 : rightRange - leftRange + 1; + + for (let i = 0; i < rangeLen && depth + i < s.length; i++) { + if (s.charCodeAt(depth + i) - CHAR_CODE_A !== a[leftRange + i]) { + return; + } + } + + let isLeaf = true; + for (let i = 0; i < ALPHABET_SIZE; ++i) { + if (t[node][i] !== -1) { + isLeaf = false; + dfs(t[node][i], depth + rangeLen); + } + } + + if (isLeaf && depth >= s.length) { + occurrences.push(a.length - (depth + rangeLen)); + } + } + + dfs(0, 0); + return occurrences; + } + + return { + build, + findSubstring, + findSubstringRecursive, + }; +} + +function performanceProfile(input: string, search = 'sasha') { + const {build, findSubstring, findSubstringRecursive} = makeTree(stringToArray(input)); + + const buildStart = performance.now(); + build(); + const buildEnd = performance.now(); + console.log('Building time:', buildEnd - buildStart, 'ms'); + + const searchStart = performance.now(); + const results = findSubstring(search); + const searchEnd = performance.now(); + console.log('Search time:', searchEnd - searchStart, 'ms'); + console.log(results); + + const recursiveStart = performance.now(); + const resultsRecursive = findSubstringRecursive(search); + const recursiveEnd = performance.now(); + console.log('Recursive search time:', recursiveEnd - recursiveStart, 'ms'); + console.log(resultsRecursive); + + return { + buildTime: buildEnd - buildStart, + searchTime: searchEnd - searchStart, + recursiveSearchTime: recursiveEnd - recursiveStart, + }; +} + +function testEmojis() { + let searchString = ''; + Object.values(enEmojis).forEach(({keywords}) => { + searchString += `${keywords.join('')}{`; + }); + return performanceProfile(searchString, 'smile'); +} + +console.log('Read string of length', DATA.length); +function runTest() { + return performanceProfile(DATA); +} + +export {makeTree, stringToArray, runTest, testEmojis}; From 54a7b6017a602e05a983284e912c6228196b9644 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hanno=20J=2E=20G=C3=B6decke?= Date: Thu, 5 Sep 2024 13:37:46 +0200 Subject: [PATCH 004/631] add some helpful comments --- src/libs/SuffixUkkonenTree.ts | 91 ++++++++++++----------------------- 1 file changed, 31 insertions(+), 60 deletions(-) diff --git a/src/libs/SuffixUkkonenTree.ts b/src/libs/SuffixUkkonenTree.ts index 7599ab2a25f3..3bf8d2ed66a9 100644 --- a/src/libs/SuffixUkkonenTree.ts +++ b/src/libs/SuffixUkkonenTree.ts @@ -1,10 +1,20 @@ import enEmojis from '@assets/emojis/en'; -import {DATA} from './test'; const CHAR_CODE_A = 'a'.charCodeAt(0); const ALPHABET_SIZE = 28; const DELIMITER_CHAR_CODE = ALPHABET_SIZE - 2; +// TODO: +// make makeTree faster +// how to deal with unicode characters such as spanish ones? + +/** + * Converts a string to an array of numbers representing the characters of the string. + * The numbers are offset by the character code of 'a' (97). + * - This is so that the numbers from a-z are in the range 0-25. + * - 26 is for the delimiter character "{", + * - 27 is for the end character "|". + */ function stringToArray(input: string) { const res: number[] = []; for (let i = 0; i < input.length; i++) { @@ -16,13 +26,22 @@ function stringToArray(input: string) { return res; } +/** + * Makes a tree from an input string, which has been converted by {@link stringToArray}. + * **Important:** As we only support an alphabet of 26 characters, the input string should only contain characters from a-z. + * Thus, all input data must be cleaned before being passed to this function. + * If you then use this tree for search you should clean your search input as well (so that a search query of "testuser@myEmail.com" becomes "testusermyemailcom"). + */ function makeTree(a: number[]) { const N = 1000000; + const start = performance.now(); const t = Array.from({length: N}, () => Array(ALPHABET_SIZE).fill(-1)) as number[][]; const l = Array(N).fill(0) as number[]; const r = Array(N).fill(0) as number[]; const p = Array(N).fill(0) as number[]; const s = Array(N).fill(0) as number[]; + const end = performance.now(); + console.log('Allocating memory took:', end - start, 'ms'); let tv = 0; let tp = 0; @@ -113,47 +132,10 @@ function makeTree(a: number[]) { } } - function findSubstring(sString: string) { - const s = stringToArray(sString); - const occurrences: number[] = []; - const st: Array<[number, number]> = [[0, 0]]; - - while (st.length > 0) { - const [node, depth] = st.pop()!; - - let isLeaf = true; - const leftRange = l[node]; - const rightRange = r[node]; - const rangeLen = node === 0 ? 0 : rightRange - leftRange + 1; - - let matches = true; - for (let i = 0; i < rangeLen && depth + i < s.length; i++) { - if (s[depth + i] !== a[leftRange + i]) { - matches = false; - break; - } - } - - if (!matches) { - continue; - } - - for (let i = ALPHABET_SIZE - 1; i >= 0; --i) { - if (t[node][i] !== -1) { - isLeaf = false; - st.push([t[node][i], depth + rangeLen]); - } - } - - if (isLeaf && depth + rangeLen >= s.length) { - occurrences.push(a.length - (depth + rangeLen)); - } - } - - return occurrences; - } - - function findSubstringRecursive(s: string) { + /** + * Returns all occurrences of the given (sub)string in the input string. + */ + function findSubstring(searchString: string) { const occurrences: number[] = []; function dfs(node: number, depth: number) { @@ -161,8 +143,8 @@ function makeTree(a: number[]) { const rightRange = r[node]; const rangeLen = node === 0 ? 0 : rightRange - leftRange + 1; - for (let i = 0; i < rangeLen && depth + i < s.length; i++) { - if (s.charCodeAt(depth + i) - CHAR_CODE_A !== a[leftRange + i]) { + for (let i = 0; i < rangeLen && depth + i < searchString.length; i++) { + if (searchString.charCodeAt(depth + i) - CHAR_CODE_A !== a[leftRange + i]) { return; } } @@ -175,7 +157,7 @@ function makeTree(a: number[]) { } } - if (isLeaf && depth >= s.length) { + if (isLeaf && depth >= searchString.length) { occurrences.push(a.length - (depth + rangeLen)); } } @@ -187,12 +169,12 @@ function makeTree(a: number[]) { return { build, findSubstring, - findSubstringRecursive, }; } function performanceProfile(input: string, search = 'sasha') { - const {build, findSubstring, findSubstringRecursive} = makeTree(stringToArray(input)); + // TODO: For emojis we could precalculate the stringToArray or even the makeTree function during build time using a babel plugin + const {build, findSubstring} = makeTree(stringToArray(input)); const buildStart = performance.now(); build(); @@ -205,19 +187,13 @@ function performanceProfile(input: string, search = 'sasha') { console.log('Search time:', searchEnd - searchStart, 'ms'); console.log(results); - const recursiveStart = performance.now(); - const resultsRecursive = findSubstringRecursive(search); - const recursiveEnd = performance.now(); - console.log('Recursive search time:', recursiveEnd - recursiveStart, 'ms'); - console.log(resultsRecursive); - return { buildTime: buildEnd - buildStart, - searchTime: searchEnd - searchStart, - recursiveSearchTime: recursiveEnd - recursiveStart, + recursiveSearchTime: searchEnd - searchStart, }; } +// Demo function testing the performance for emojis function testEmojis() { let searchString = ''; Object.values(enEmojis).forEach(({keywords}) => { @@ -226,9 +202,4 @@ function testEmojis() { return performanceProfile(searchString, 'smile'); } -console.log('Read string of length', DATA.length); -function runTest() { - return performanceProfile(DATA); -} - export {makeTree, stringToArray, runTest, testEmojis}; From 8622670fd891e59e266602ae3605868d3d5da997 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hanno=20J=2E=20G=C3=B6decke?= Date: Thu, 5 Sep 2024 16:09:28 +0200 Subject: [PATCH 005/631] example implementation usage of Suffixtree --- src/libs/SuffixUkkonenTree.ts | 16 +++- src/pages/ChatFinderPage/index.tsx | 117 ++++++++++++++++++++++++++++- 2 files changed, 126 insertions(+), 7 deletions(-) diff --git a/src/libs/SuffixUkkonenTree.ts b/src/libs/SuffixUkkonenTree.ts index 3bf8d2ed66a9..217588fae5fa 100644 --- a/src/libs/SuffixUkkonenTree.ts +++ b/src/libs/SuffixUkkonenTree.ts @@ -7,10 +7,11 @@ const DELIMITER_CHAR_CODE = ALPHABET_SIZE - 2; // TODO: // make makeTree faster // how to deal with unicode characters such as spanish ones? +// i think we need to support numbers as well /** * Converts a string to an array of numbers representing the characters of the string. - * The numbers are offset by the character code of 'a' (97). + * The numbers are offset by the character code of 'a' (97). * - This is so that the numbers from a-z are in the range 0-25. * - 26 is for the delimiter character "{", * - 27 is for the end character "|". @@ -33,7 +34,7 @@ function stringToArray(input: string) { * If you then use this tree for search you should clean your search input as well (so that a search query of "testuser@myEmail.com" becomes "testusermyemailcom"). */ function makeTree(a: number[]) { - const N = 1000000; + const N = 25000; // TODO: i reduced this number from 1_000_000 down to this, for faster performance - however its possible that it needs to be bigger for larger search strings const start = performance.now(); const t = Array.from({length: N}, () => Array(ALPHABET_SIZE).fill(-1)) as number[][]; const l = Array(N).fill(0) as number[]; @@ -134,6 +135,15 @@ function makeTree(a: number[]) { /** * Returns all occurrences of the given (sub)string in the input string. + * + * You can think of the tree that we create as a big string that looks like this: + * + * "banana{pancake{apple|" + * The delimiter character '{' is used to separate the different strings. + * The end character '|' is used to indicate the end of our search string. + * + * This function will return the index(es) of found occurrences within this big string. + * So, when searching for "an", it would return [1, 4, 11]. */ function findSubstring(searchString: string) { const occurrences: number[] = []; @@ -202,4 +212,4 @@ function testEmojis() { return performanceProfile(searchString, 'smile'); } -export {makeTree, stringToArray, runTest, testEmojis}; +export {makeTree, stringToArray, testEmojis}; diff --git a/src/pages/ChatFinderPage/index.tsx b/src/pages/ChatFinderPage/index.tsx index aabf881a8bed..cbdf5ec739c1 100644 --- a/src/pages/ChatFinderPage/index.tsx +++ b/src/pages/ChatFinderPage/index.tsx @@ -18,6 +18,7 @@ import type {RootStackParamList} from '@libs/Navigation/types'; import * as OptionsListUtils from '@libs/OptionsListUtils'; import Performance from '@libs/Performance'; import type {OptionData} from '@libs/ReportUtils'; +import {makeTree, stringToArray} from '@libs/SuffixUkkonenTree'; import * as Report from '@userActions/Report'; import Timing from '@userActions/Timing'; import CONST from '@src/CONST'; @@ -51,6 +52,8 @@ const setPerformanceTimersEnd = () => { const ChatFinderPageFooterInstance = ; +const aToZRegex = /[^a-z]/gi; + function ChatFinderPage({betas, isSearchingForReports, navigation}: ChatFinderPageProps) { const [isScreenTransitionEnd, setIsScreenTransitionEnd] = useState(false); const {translate} = useLocalize(); @@ -94,6 +97,112 @@ function ChatFinderPage({betas, isSearchingForReports, navigation}: ChatFinderPa return {...optionList, headerMessage: header}; }, [areOptionsInitialized, betas, isScreenTransitionEnd, options]); + /** + * Builds a suffix tree and returns a function to search in it. + * + * // TODO: + * - The results we get from tree.findSubstring are the indexes of the occurrence in the original string + * I implemented a manual mapping function here, we probably want to put that inside the tree implementation + * (including the implementation detail of the delimiter character) + */ + const findInSearchTree = useMemo(() => { + // The character that separates the different options in the search string + const delimiterChar = '{'; + + const searchIndexListRecentReports: Array = []; + const searchIndexListPersonalDetails: Array = []; + + let start = performance.now(); + let searchString = searchOptions.personalDetails + .map((option) => { + // TODO: there are probably more fields we'd like to add to the search string + let searchStringForTree = (option.login ?? '') + (option.login !== option.displayName ? option.displayName ?? '' : ''); + // Remove all none a-z chars: + searchStringForTree = searchStringForTree.toLowerCase().replace(aToZRegex, ''); + + if (searchStringForTree.length > 0) { + // We need to push an array that has the same length as the length of the string we insert for this option: + const indexes = Array.from({length: searchStringForTree.length}, () => option); + // Note: we add undefined for the delimiter character + searchIndexListPersonalDetails.push(...indexes, undefined); + } else { + return undefined; + } + + return searchStringForTree; + }) + .filter(Boolean) + .join(delimiterChar); + searchString += searchOptions.recentReports + .map((option) => { + let searchStringForTree = (option.login ?? '') + (option.login !== option.displayName ? option.displayName ?? '' : ''); + searchStringForTree += option.reportID ?? ''; + searchStringForTree += option.name ?? ''; + // Remove all none a-z chars: + searchStringForTree = searchStringForTree.toLowerCase().replace(aToZRegex, ''); + + if (searchStringForTree.length > 0) { + // We need to push an array that has the same length as the length of the string we insert for this option: + const indexes = Array.from({length: searchStringForTree.length}, () => option); + searchIndexListRecentReports.push(...indexes, undefined); + } else { + return undefined; + } + + return searchStringForTree; + }) + // TODO: this can probably improved by a reduce + .filter(Boolean) + .join(delimiterChar); + searchString += '|'; // End Character + console.log(searchIndexListPersonalDetails.slice(0, 20)); + console.log(searchString.substring(0, 20)); + console.log('building search strings', performance.now() - start); + + // TODO: stringToArray is probably also an implementation detail we want to hide from the developer + start = performance.now(); + const numbers = stringToArray(searchString); + console.log('stringToArray', performance.now() - start); + start = performance.now(); + const tree = makeTree(numbers); + console.log('makeTree', performance.now() - start); + start = performance.now(); + tree.build(); + console.log('build', performance.now() - start); + + function search(searchInput: string) { + start = performance.now(); + const result = tree.findSubstring(searchInput); + console.log('FindSubstring index result for searchInput', searchInput, result); + // Map the results to the original options + const mappedResults = { + personalDetails: [] as OptionData[], + recentReports: [] as OptionData[], + }; + result.forEach((index) => { + // const textInSearchString = searchString.substring(index, searchString.indexOf(delimiterChar, index)); + // console.log('textInSearchString', textInSearchString); + + if (index < searchIndexListPersonalDetails.length) { + const option = searchIndexListPersonalDetails[index]; + if (option) { + mappedResults.personalDetails.push(option); + } + } else { + const option = searchIndexListRecentReports[index - searchIndexListPersonalDetails.length]; + if (option) { + mappedResults.recentReports.push(option); + } + } + }); + + console.log('search', performance.now() - start); + return mappedResults; + } + + return search; + }, [searchOptions.personalDetails, searchOptions.recentReports]); + const filteredOptions = useMemo(() => { if (debouncedSearchValue.trim() === '') { return { @@ -105,17 +214,17 @@ function ChatFinderPage({betas, isSearchingForReports, navigation}: ChatFinderPa } Timing.start(CONST.TIMING.SEARCH_FILTER_OPTIONS); - const newOptions = OptionsListUtils.filterOptions(searchOptions, debouncedSearchValue, {sortByReportTypeInSearch: true, preferChatroomsOverThreads: true}); + const newOptions = findInSearchTree(debouncedSearchValue.toLowerCase().replace(aToZRegex, '')); Timing.end(CONST.TIMING.SEARCH_FILTER_OPTIONS); - const header = OptionsListUtils.getHeaderMessage(newOptions.recentReports.length + Number(!!newOptions.userToInvite) > 0, false, debouncedSearchValue); + const header = OptionsListUtils.getHeaderMessage(newOptions.recentReports.length > 0, false, debouncedSearchValue); return { recentReports: newOptions.recentReports, personalDetails: newOptions.personalDetails, - userToInvite: newOptions.userToInvite, + userToInvite: undefined, // newOptions.userToInvite, headerMessage: header, }; - }, [debouncedSearchValue, searchOptions]); + }, [debouncedSearchValue, findInSearchTree]); const {recentReports, personalDetails: localPersonalDetails, userToInvite, headerMessage} = debouncedSearchValue.trim() !== '' ? filteredOptions : searchOptions; From 01162fee73c8147556d65e4b5990f0a25d855d7e Mon Sep 17 00:00:00 2001 From: kirillzyusko Date: Wed, 11 Sep 2024 17:39:36 +0200 Subject: [PATCH 006/631] fix: resolved one TODO --- src/libs/SuffixUkkonenTree.ts | 14 ++++++++------ src/pages/ChatFinderPage/index.tsx | 8 ++------ 2 files changed, 10 insertions(+), 12 deletions(-) diff --git a/src/libs/SuffixUkkonenTree.ts b/src/libs/SuffixUkkonenTree.ts index 217588fae5fa..52a7ebb2b7d9 100644 --- a/src/libs/SuffixUkkonenTree.ts +++ b/src/libs/SuffixUkkonenTree.ts @@ -28,15 +28,16 @@ function stringToArray(input: string) { } /** - * Makes a tree from an input string, which has been converted by {@link stringToArray}. + * Makes a tree from an input string * **Important:** As we only support an alphabet of 26 characters, the input string should only contain characters from a-z. * Thus, all input data must be cleaned before being passed to this function. * If you then use this tree for search you should clean your search input as well (so that a search query of "testuser@myEmail.com" becomes "testusermyemailcom"). */ -function makeTree(a: number[]) { +function makeTree(searchString: string) { + const a = stringToArray(searchString); const N = 25000; // TODO: i reduced this number from 1_000_000 down to this, for faster performance - however its possible that it needs to be bigger for larger search strings const start = performance.now(); - const t = Array.from({length: N}, () => Array(ALPHABET_SIZE).fill(-1)) as number[][]; + const t = Array.from({length: N}, () => Array(ALPHABET_SIZE).fill(-1) as number[]); const l = Array(N).fill(0) as number[]; const r = Array(N).fill(0) as number[]; const p = Array(N).fill(0) as number[]; @@ -183,8 +184,9 @@ function makeTree(a: number[]) { } function performanceProfile(input: string, search = 'sasha') { - // TODO: For emojis we could precalculate the stringToArray or even the makeTree function during build time using a babel plugin - const {build, findSubstring} = makeTree(stringToArray(input)); + // TODO: For emojis we could precalculate the makeTree function during build time using a babel plugin + // maybe babel plugin that just precalculates the result of function execution (so that it can be generic purpose plugin) + const {build, findSubstring} = makeTree(input); const buildStart = performance.now(); build(); @@ -212,4 +214,4 @@ function testEmojis() { return performanceProfile(searchString, 'smile'); } -export {makeTree, stringToArray, testEmojis}; +export {makeTree, testEmojis}; diff --git a/src/pages/ChatFinderPage/index.tsx b/src/pages/ChatFinderPage/index.tsx index cbdf5ec739c1..f7860d4cc1e3 100644 --- a/src/pages/ChatFinderPage/index.tsx +++ b/src/pages/ChatFinderPage/index.tsx @@ -18,7 +18,7 @@ import type {RootStackParamList} from '@libs/Navigation/types'; import * as OptionsListUtils from '@libs/OptionsListUtils'; import Performance from '@libs/Performance'; import type {OptionData} from '@libs/ReportUtils'; -import {makeTree, stringToArray} from '@libs/SuffixUkkonenTree'; +import {makeTree} from '@libs/SuffixUkkonenTree'; import * as Report from '@userActions/Report'; import Timing from '@userActions/Timing'; import CONST from '@src/CONST'; @@ -159,12 +159,8 @@ function ChatFinderPage({betas, isSearchingForReports, navigation}: ChatFinderPa console.log(searchString.substring(0, 20)); console.log('building search strings', performance.now() - start); - // TODO: stringToArray is probably also an implementation detail we want to hide from the developer start = performance.now(); - const numbers = stringToArray(searchString); - console.log('stringToArray', performance.now() - start); - start = performance.now(); - const tree = makeTree(numbers); + const tree = makeTree(searchString); console.log('makeTree', performance.now() - start); start = performance.now(); tree.build(); From cbdc5304a71b21f278ecbb8c24c0f438a6e32541 Mon Sep 17 00:00:00 2001 From: daledah Date: Thu, 12 Sep 2024 14:29:22 +0700 Subject: [PATCH 007/631] fix: Error when selecting distance rate that no longer has tax rate --- src/libs/actions/TaxRate.ts | 48 +++++++++++++++++++ .../PolicyDistanceRateDetailsPage.tsx | 2 +- 2 files changed, 49 insertions(+), 1 deletion(-) diff --git a/src/libs/actions/TaxRate.ts b/src/libs/actions/TaxRate.ts index 514b73915633..c8a2962d7dc4 100644 --- a/src/libs/actions/TaxRate.ts +++ b/src/libs/actions/TaxRate.ts @@ -286,6 +286,10 @@ function deletePolicyTaxes(policyID: string, taxesToDelete: string[]) { const policyTaxRates = policy?.taxRates?.taxes; const foreignTaxDefault = policy?.taxRates?.foreignTaxDefault; const firstTaxID = Object.keys(policyTaxRates ?? {}).sort((a, b) => a.localeCompare(b))[0]; + const customUnits = policy?.customUnits ?? {}; + const ratesToUpdate = Object.values(customUnits?.[Object.keys(customUnits)[0]]?.rates).filter( + (rate) => !!rate.attributes?.taxRateExternalID && taxesToDelete.includes(rate.attributes?.taxRateExternalID), + ); if (!policyTaxRates) { console.debug('Policy or tax rates not found'); @@ -294,6 +298,35 @@ function deletePolicyTaxes(policyID: string, taxesToDelete: string[]) { const isForeignTaxRemoved = foreignTaxDefault && taxesToDelete.includes(foreignTaxDefault); + const optimisticRates: Record = {}; + const successRates: Record = {}; + const failureRates: Record = {}; + + ratesToUpdate.forEach((rate) => { + const rateID = rate.customUnitRateID ?? ''; + optimisticRates[rateID] = { + ...rate, + attributes: { + ...rate?.attributes, + taxRateExternalID: undefined, + taxClaimablePercentage: undefined, + }, + pendingFields: { + taxRateExternalID: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, + taxClaimablePercentage: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, + }, + }; + successRates[rateID] = {...rate, pendingFields: {taxRateExternalID: null}}; + failureRates[rateID] = { + ...rate, + pendingFields: {taxRateExternalID: null, taxClaimablePercentage: null}, + errorFields: { + taxRateExternalID: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('common.genericErrorMessage'), + taxClaimablePercentage: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('common.genericErrorMessage'), + }, + }; + }); + const onyxData: OnyxData = { optimisticData: [ { @@ -308,6 +341,11 @@ function deletePolicyTaxes(policyID: string, taxesToDelete: string[]) { return acc; }, {}), }, + customUnits: policy.customUnits && { + [Object.keys(policy.customUnits)[0]]: { + rates: optimisticRates, + }, + }, }, }, ], @@ -323,6 +361,11 @@ function deletePolicyTaxes(policyID: string, taxesToDelete: string[]) { return acc; }, {}), }, + customUnits: policy.customUnits && { + [Object.keys(policy.customUnits)[0]]: { + rates: successRates, + }, + }, }, }, ], @@ -341,6 +384,11 @@ function deletePolicyTaxes(policyID: string, taxesToDelete: string[]) { return acc; }, {}), }, + customUnits: policy.customUnits && { + [Object.keys(policy.customUnits)[0]]: { + rates: failureRates, + }, + }, }, }, ], diff --git a/src/pages/workspace/distanceRates/PolicyDistanceRateDetailsPage.tsx b/src/pages/workspace/distanceRates/PolicyDistanceRateDetailsPage.tsx index c4d033351b37..982e4799d781 100644 --- a/src/pages/workspace/distanceRates/PolicyDistanceRateDetailsPage.tsx +++ b/src/pages/workspace/distanceRates/PolicyDistanceRateDetailsPage.tsx @@ -155,7 +155,7 @@ function PolicyDistanceRateDetailsPage({policy, route}: PolicyDistanceRateDetail )} - {isDistanceTrackTaxEnabled && isPolicyTrackTaxEnabled && ( + {isDistanceTrackTaxEnabled && !!taxRate && isPolicyTrackTaxEnabled && ( Date: Thu, 12 Sep 2024 14:59:11 +0700 Subject: [PATCH 008/631] fix: undefined --- src/libs/actions/TaxRate.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/libs/actions/TaxRate.ts b/src/libs/actions/TaxRate.ts index c8a2962d7dc4..c9073bd4a668 100644 --- a/src/libs/actions/TaxRate.ts +++ b/src/libs/actions/TaxRate.ts @@ -287,7 +287,7 @@ function deletePolicyTaxes(policyID: string, taxesToDelete: string[]) { const foreignTaxDefault = policy?.taxRates?.foreignTaxDefault; const firstTaxID = Object.keys(policyTaxRates ?? {}).sort((a, b) => a.localeCompare(b))[0]; const customUnits = policy?.customUnits ?? {}; - const ratesToUpdate = Object.values(customUnits?.[Object.keys(customUnits)[0]]?.rates).filter( + const ratesToUpdate = Object.values(customUnits?.[Object.keys(customUnits)[0]]?.rates ?? {}).filter( (rate) => !!rate.attributes?.taxRateExternalID && taxesToDelete.includes(rate.attributes?.taxRateExternalID), ); From 09e8aa7362424a49e1e8889bb97bc15ba6648dc5 Mon Sep 17 00:00:00 2001 From: kirillzyusko Date: Thu, 12 Sep 2024 12:42:19 +0200 Subject: [PATCH 009/631] fix: reduce code duplication --- src/libs/SuffixUkkonenTree.ts | 36 +++++++++++++++++-- src/pages/ChatFinderPage/index.tsx | 56 +++++++----------------------- 2 files changed, 46 insertions(+), 46 deletions(-) diff --git a/src/libs/SuffixUkkonenTree.ts b/src/libs/SuffixUkkonenTree.ts index 52a7ebb2b7d9..2a0d0d309a48 100644 --- a/src/libs/SuffixUkkonenTree.ts +++ b/src/libs/SuffixUkkonenTree.ts @@ -27,14 +27,44 @@ function stringToArray(input: string) { return res; } +const aToZRegex = /[^a-z]/gi; +// The character that separates the different options in the search string +const delimiterChar = '{'; + +function prepareData({data, transform}: {data: T[]; transform: (data: T) => string}): [string, Array] { + const searchIndexList: Array = []; + const str = data + .map((option) => { + let searchStringForTree = transform(option); + // Remove all none a-z chars: + searchStringForTree = searchStringForTree.toLowerCase().replace(aToZRegex, ''); + + if (searchStringForTree.length > 0) { + // We need to push an array that has the same length as the length of the string we insert for this option: + const indexes = Array.from({length: searchStringForTree.length}, () => option); + // Note: we add undefined for the delimiter character + searchIndexList.push(...indexes, undefined); + } else { + return undefined; + } + + return searchStringForTree; + }) + // TODO: this can probably improved by a reduce + .filter(Boolean) + .join(delimiterChar); + + return [str, searchIndexList]; +} + /** * Makes a tree from an input string * **Important:** As we only support an alphabet of 26 characters, the input string should only contain characters from a-z. * Thus, all input data must be cleaned before being passed to this function. * If you then use this tree for search you should clean your search input as well (so that a search query of "testuser@myEmail.com" becomes "testusermyemailcom"). */ -function makeTree(searchString: string) { - const a = stringToArray(searchString); +function makeTree(stringToSearch: string) { + const a = stringToArray(stringToSearch); const N = 25000; // TODO: i reduced this number from 1_000_000 down to this, for faster performance - however its possible that it needs to be bigger for larger search strings const start = performance.now(); const t = Array.from({length: N}, () => Array(ALPHABET_SIZE).fill(-1) as number[]); @@ -214,4 +244,4 @@ function testEmojis() { return performanceProfile(searchString, 'smile'); } -export {makeTree, testEmojis}; +export {makeTree, prepareData, testEmojis}; diff --git a/src/pages/ChatFinderPage/index.tsx b/src/pages/ChatFinderPage/index.tsx index f7860d4cc1e3..97feafa892b7 100644 --- a/src/pages/ChatFinderPage/index.tsx +++ b/src/pages/ChatFinderPage/index.tsx @@ -18,7 +18,7 @@ import type {RootStackParamList} from '@libs/Navigation/types'; import * as OptionsListUtils from '@libs/OptionsListUtils'; import Performance from '@libs/Performance'; import type {OptionData} from '@libs/ReportUtils'; -import {makeTree} from '@libs/SuffixUkkonenTree'; +import {makeTree, prepareData} from '@libs/SuffixUkkonenTree'; import * as Report from '@userActions/Report'; import Timing from '@userActions/Timing'; import CONST from '@src/CONST'; @@ -106,55 +106,25 @@ function ChatFinderPage({betas, isSearchingForReports, navigation}: ChatFinderPa * (including the implementation detail of the delimiter character) */ const findInSearchTree = useMemo(() => { - // The character that separates the different options in the search string - const delimiterChar = '{'; - - const searchIndexListRecentReports: Array = []; - const searchIndexListPersonalDetails: Array = []; - let start = performance.now(); - let searchString = searchOptions.personalDetails - .map((option) => { + const [personalDetailsSearchString, searchIndexListPersonalDetails] = prepareData({ + data: searchOptions.personalDetails, + transform: (option) => { // TODO: there are probably more fields we'd like to add to the search string - let searchStringForTree = (option.login ?? '') + (option.login !== option.displayName ? option.displayName ?? '' : ''); - // Remove all none a-z chars: - searchStringForTree = searchStringForTree.toLowerCase().replace(aToZRegex, ''); - - if (searchStringForTree.length > 0) { - // We need to push an array that has the same length as the length of the string we insert for this option: - const indexes = Array.from({length: searchStringForTree.length}, () => option); - // Note: we add undefined for the delimiter character - searchIndexListPersonalDetails.push(...indexes, undefined); - } else { - return undefined; - } - - return searchStringForTree; - }) - .filter(Boolean) - .join(delimiterChar); - searchString += searchOptions.recentReports - .map((option) => { + return (option.login ?? '') + (option.login !== option.displayName ? option.displayName ?? '' : ''); + }, + }); + const [recentReportsSearchString, searchIndexListRecentReports] = prepareData({ + data: searchOptions.recentReports, + transform: (option) => { let searchStringForTree = (option.login ?? '') + (option.login !== option.displayName ? option.displayName ?? '' : ''); searchStringForTree += option.reportID ?? ''; searchStringForTree += option.name ?? ''; - // Remove all none a-z chars: - searchStringForTree = searchStringForTree.toLowerCase().replace(aToZRegex, ''); - - if (searchStringForTree.length > 0) { - // We need to push an array that has the same length as the length of the string we insert for this option: - const indexes = Array.from({length: searchStringForTree.length}, () => option); - searchIndexListRecentReports.push(...indexes, undefined); - } else { - return undefined; - } return searchStringForTree; - }) - // TODO: this can probably improved by a reduce - .filter(Boolean) - .join(delimiterChar); - searchString += '|'; // End Character + }, + }); + const searchString = `${personalDetailsSearchString}${recentReportsSearchString}|`; // End Character console.log(searchIndexListPersonalDetails.slice(0, 20)); console.log(searchString.substring(0, 20)); console.log('building search strings', performance.now() - start); From fa81e13878d8d35b44b374e7a454f02cfc3cdc37 Mon Sep 17 00:00:00 2001 From: kirillzyusko Date: Thu, 12 Sep 2024 13:01:13 +0200 Subject: [PATCH 010/631] refactor: O(2) -> O(1) --- src/libs/SuffixUkkonenTree.ts | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/src/libs/SuffixUkkonenTree.ts b/src/libs/SuffixUkkonenTree.ts index 2a0d0d309a48..fc11e194b8e0 100644 --- a/src/libs/SuffixUkkonenTree.ts +++ b/src/libs/SuffixUkkonenTree.ts @@ -50,9 +50,18 @@ function prepareData({data, transform}: {data: T[]; transform: (data: T) => s return searchStringForTree; }) - // TODO: this can probably improved by a reduce - .filter(Boolean) - .join(delimiterChar); + // slightly faster alternative to `.filter(Boolean).join(delimiterChar)` + .reduce((acc: string, curr) => { + if (!curr) { + return acc; + } + + if (acc === '') { + return curr; + } + + return `${acc}${delimiterChar}${curr}`; + }, ''); return [str, searchIndexList]; } From e33142fd2e137cc2bcdb1b82a4cea17a74c08387 Mon Sep 17 00:00:00 2001 From: kirillzyusko Date: Thu, 12 Sep 2024 15:38:59 +0200 Subject: [PATCH 011/631] refactor: minus one TODO --- src/libs/SuffixUkkonenTree.ts | 72 +++++++++++++++++++++++++---- src/pages/ChatFinderPage/index.tsx | 74 +++++++++--------------------- 2 files changed, 85 insertions(+), 61 deletions(-) diff --git a/src/libs/SuffixUkkonenTree.ts b/src/libs/SuffixUkkonenTree.ts index fc11e194b8e0..6d56c59a4ab4 100644 --- a/src/libs/SuffixUkkonenTree.ts +++ b/src/libs/SuffixUkkonenTree.ts @@ -31,7 +31,12 @@ const aToZRegex = /[^a-z]/gi; // The character that separates the different options in the search string const delimiterChar = '{'; -function prepareData({data, transform}: {data: T[]; transform: (data: T) => string}): [string, Array] { +type PrepareDataParams = { + data: T[]; + transform: (data: T) => string; +}; + +function prepareData({data, transform}: PrepareDataParams): [string, Array] { const searchIndexList: Array = []; const str = data .map((option) => { @@ -72,7 +77,19 @@ function prepareData({data, transform}: {data: T[]; transform: (data: T) => s * Thus, all input data must be cleaned before being passed to this function. * If you then use this tree for search you should clean your search input as well (so that a search query of "testuser@myEmail.com" becomes "testusermyemailcom"). */ -function makeTree(stringToSearch: string) { +function makeTree(compose: Array>) { + const start1 = performance.now(); + const strings = []; + const indexes: Array> = []; + + for (const {data, transform} of compose) { + const [str, searchIndexList] = prepareData({data, transform}); + strings.push(str); + indexes.push(searchIndexList); + } + const stringToSearch = `${strings.join('')}|`; // End Character + console.log('building search strings', performance.now() - start1); + const a = stringToArray(stringToSearch); const N = 25000; // TODO: i reduced this number from 1_000_000 down to this, for faster performance - however its possible that it needs to be bigger for larger search strings const start = performance.now(); @@ -216,16 +233,48 @@ function makeTree(stringToSearch: string) { return occurrences; } + function findInSearchTree(searchInput: string) { + const now = performance.now(); + const result = findSubstring(searchInput); + console.log('FindSubstring index result for searchInput', searchInput, result); + // Map the results to the original options + + const mappedResults: T[][] = Array.from({length: compose.length}, () => []); + console.log({result}); + result.forEach((index) => { + // const textInSearchString = searchString.substring(index, searchString.indexOf(delimiterChar, index)); + // console.log('textInSearchString', textInSearchString); + + // TODO: check with Hanno whether we restore the data correctly + let offset = 0; + for (let i = 0; i < indexes.length; i++) { + const relativeIndex = index - offset; + if (relativeIndex < indexes[i].length && relativeIndex >= 0) { + const option = indexes[i][relativeIndex]; + if (option) { + mappedResults[i].push(option); + } + } else { + offset += indexes[i].length; + } + } + }); + + console.log('search', performance.now() - now); + return mappedResults; + } + return { build, findSubstring, + findInSearchTree, }; } -function performanceProfile(input: string, search = 'sasha') { +function performanceProfile(input: PrepareDataParams, search = 'sasha') { // TODO: For emojis we could precalculate the makeTree function during build time using a babel plugin // maybe babel plugin that just precalculates the result of function execution (so that it can be generic purpose plugin) - const {build, findSubstring} = makeTree(input); + const {build, findSubstring} = makeTree([input]); const buildStart = performance.now(); build(); @@ -246,11 +295,16 @@ function performanceProfile(input: string, search = 'sasha') { // Demo function testing the performance for emojis function testEmojis() { - let searchString = ''; - Object.values(enEmojis).forEach(({keywords}) => { - searchString += `${keywords.join('')}{`; - }); - return performanceProfile(searchString, 'smile'); + const data = Object.values(enEmojis); + return performanceProfile( + { + data, + transform: ({keywords}) => { + return `${keywords.join('')}{`; + }, + }, + 'smile', + ); } export {makeTree, prepareData, testEmojis}; diff --git a/src/pages/ChatFinderPage/index.tsx b/src/pages/ChatFinderPage/index.tsx index 97feafa892b7..2afe1fd96e3d 100644 --- a/src/pages/ChatFinderPage/index.tsx +++ b/src/pages/ChatFinderPage/index.tsx @@ -99,38 +99,28 @@ function ChatFinderPage({betas, isSearchingForReports, navigation}: ChatFinderPa /** * Builds a suffix tree and returns a function to search in it. - * - * // TODO: - * - The results we get from tree.findSubstring are the indexes of the occurrence in the original string - * I implemented a manual mapping function here, we probably want to put that inside the tree implementation - * (including the implementation detail of the delimiter character) */ const findInSearchTree = useMemo(() => { let start = performance.now(); - const [personalDetailsSearchString, searchIndexListPersonalDetails] = prepareData({ - data: searchOptions.personalDetails, - transform: (option) => { - // TODO: there are probably more fields we'd like to add to the search string - return (option.login ?? '') + (option.login !== option.displayName ? option.displayName ?? '' : ''); + const tree = makeTree([ + { + data: searchOptions.personalDetails, + transform: (option) => { + // TODO: there are probably more fields we'd like to add to the search string + return (option.login ?? '') + (option.login !== option.displayName ? option.displayName ?? '' : ''); + }, }, - }); - const [recentReportsSearchString, searchIndexListRecentReports] = prepareData({ - data: searchOptions.recentReports, - transform: (option) => { - let searchStringForTree = (option.login ?? '') + (option.login !== option.displayName ? option.displayName ?? '' : ''); - searchStringForTree += option.reportID ?? ''; - searchStringForTree += option.name ?? ''; - - return searchStringForTree; + { + data: searchOptions.recentReports, + transform: (option) => { + let searchStringForTree = (option.login ?? '') + (option.login !== option.displayName ? option.displayName ?? '' : ''); + searchStringForTree += option.reportID ?? ''; + searchStringForTree += option.name ?? ''; + + return searchStringForTree; + }, }, - }); - const searchString = `${personalDetailsSearchString}${recentReportsSearchString}|`; // End Character - console.log(searchIndexListPersonalDetails.slice(0, 20)); - console.log(searchString.substring(0, 20)); - console.log('building search strings', performance.now() - start); - - start = performance.now(); - const tree = makeTree(searchString); + ]); console.log('makeTree', performance.now() - start); start = performance.now(); tree.build(); @@ -138,32 +128,12 @@ function ChatFinderPage({betas, isSearchingForReports, navigation}: ChatFinderPa function search(searchInput: string) { start = performance.now(); - const result = tree.findSubstring(searchInput); - console.log('FindSubstring index result for searchInput', searchInput, result); - // Map the results to the original options - const mappedResults = { - personalDetails: [] as OptionData[], - recentReports: [] as OptionData[], - }; - result.forEach((index) => { - // const textInSearchString = searchString.substring(index, searchString.indexOf(delimiterChar, index)); - // console.log('textInSearchString', textInSearchString); - - if (index < searchIndexListPersonalDetails.length) { - const option = searchIndexListPersonalDetails[index]; - if (option) { - mappedResults.personalDetails.push(option); - } - } else { - const option = searchIndexListRecentReports[index - searchIndexListPersonalDetails.length]; - if (option) { - mappedResults.recentReports.push(option); - } - } - }); + const [personalDetails, recentReports] = tree.findInSearchTree(searchInput); - console.log('search', performance.now() - start); - return mappedResults; + return { + personalDetails, + recentReports, + }; } return search; From 30424a9e0fa91e8dc7a8e4c3ffb30363a5d5f4d9 Mon Sep 17 00:00:00 2001 From: kirillzyusko Date: Fri, 13 Sep 2024 13:56:43 +0200 Subject: [PATCH 012/631] fix: bring back userToInvite --- src/libs/OptionsListUtils.ts | 37 ++++++++++++++++++++++-------- src/pages/ChatFinderPage/index.tsx | 12 ++++++++-- 2 files changed, 37 insertions(+), 12 deletions(-) diff --git a/src/libs/OptionsListUtils.ts b/src/libs/OptionsListUtils.ts index f191c1d06532..9b850c382400 100644 --- a/src/libs/OptionsListUtils.ts +++ b/src/libs/OptionsListUtils.ts @@ -2382,6 +2382,31 @@ function getPersonalDetailSearchTerms(item: Partial) { function getCurrentUserSearchTerms(item: ReportUtils.OptionData) { return [item.text ?? '', item.login ?? '', item.login?.replace(CONST.EMAIL_SEARCH_REGEX, '') ?? '']; } + +type PickUserToInviteParams = { + canInviteUser: boolean; + recentReports: ReportUtils.OptionData[]; + personalDetails: ReportUtils.OptionData[]; + searchValue: string; + config?: FilterOptionsConfig; + optionsToExclude: Option[]; +}; + +const pickUserToInvite = ({canInviteUser, recentReports, personalDetails, searchValue, config, optionsToExclude}: PickUserToInviteParams) => { + let userToInvite = null; + if (canInviteUser) { + if (recentReports.length === 0 && personalDetails.length === 0) { + userToInvite = getUserToInviteOption({ + searchValue, + selectedOptions: config?.selectedOptions, + optionsToExclude, + }); + } + } + + return userToInvite; +}; + /** * Filters options based on the search input value */ @@ -2457,16 +2482,7 @@ function filterOptions(options: Options, searchInputValue: string, config?: Filt recentReports = orderOptions(recentReports, searchValue); } - let userToInvite = null; - if (canInviteUser) { - if (recentReports.length === 0 && personalDetails.length === 0) { - userToInvite = getUserToInviteOption({ - searchValue, - selectedOptions: config?.selectedOptions, - optionsToExclude, - }); - } - } + const userToInvite = pickUserToInvite({canInviteUser, recentReports, personalDetails, searchValue, config, optionsToExclude}); if (maxRecentReportsToShow > 0 && recentReports.length > maxRecentReportsToShow) { recentReports.splice(maxRecentReportsToShow); @@ -2549,6 +2565,7 @@ export { getEmptyOptions, shouldUseBoldText, getAlternateText, + pickUserToInvite, }; export type {MemberForList, CategorySection, CategoryTreeSection, Options, OptionList, SearchOption, PayeePersonalDetails, Category, Tax, TaxRatesOption, Option, OptionTree}; diff --git a/src/pages/ChatFinderPage/index.tsx b/src/pages/ChatFinderPage/index.tsx index 2afe1fd96e3d..facde356ff12 100644 --- a/src/pages/ChatFinderPage/index.tsx +++ b/src/pages/ChatFinderPage/index.tsx @@ -150,17 +150,25 @@ function ChatFinderPage({betas, isSearchingForReports, navigation}: ChatFinderPa } Timing.start(CONST.TIMING.SEARCH_FILTER_OPTIONS); + const newOptions1 = OptionsListUtils.filterOptions(searchOptions, debouncedSearchValue, {sortByReportTypeInSearch: true, preferChatroomsOverThreads: true}); const newOptions = findInSearchTree(debouncedSearchValue.toLowerCase().replace(aToZRegex, '')); + const userToInvite = OptionsListUtils.pickUserToInvite({ + canInviteUser: true, + recentReports: newOptions.recentReports, + personalDetails: newOptions.personalDetails, + searchValue: debouncedSearchValue, + optionsToExclude: [{login: CONST.EMAIL.NOTIFICATIONS}], + }); Timing.end(CONST.TIMING.SEARCH_FILTER_OPTIONS); const header = OptionsListUtils.getHeaderMessage(newOptions.recentReports.length > 0, false, debouncedSearchValue); return { recentReports: newOptions.recentReports, personalDetails: newOptions.personalDetails, - userToInvite: undefined, // newOptions.userToInvite, + userToInvite, headerMessage: header, }; - }, [debouncedSearchValue, findInSearchTree]); + }, [debouncedSearchValue, searchOptions, findInSearchTree]); const {recentReports, personalDetails: localPersonalDetails, userToInvite, headerMessage} = debouncedSearchValue.trim() !== '' ? filteredOptions : searchOptions; From 1d11ed2d534a13b3f17dbeb5ed836489ffef6c25 Mon Sep 17 00:00:00 2001 From: kirillzyusko Date: Fri, 13 Sep 2024 15:59:43 +0200 Subject: [PATCH 013/631] fix: make Marc discoverable again (when we search in second array, then we will always get - so we add +1 bias) --- src/libs/SuffixUkkonenTree.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/libs/SuffixUkkonenTree.ts b/src/libs/SuffixUkkonenTree.ts index 6d56c59a4ab4..7a7bb1bd4a3c 100644 --- a/src/libs/SuffixUkkonenTree.ts +++ b/src/libs/SuffixUkkonenTree.ts @@ -248,7 +248,7 @@ function makeTree(compose: Array>) { // TODO: check with Hanno whether we restore the data correctly let offset = 0; for (let i = 0; i < indexes.length; i++) { - const relativeIndex = index - offset; + const relativeIndex = index - offset + 1; if (relativeIndex < indexes[i].length && relativeIndex >= 0) { const option = indexes[i][relativeIndex]; if (option) { From 061efbf7b5e1798a79b3eee056a3fc5cb7e28672 Mon Sep 17 00:00:00 2001 From: SIMalik Date: Sat, 14 Sep 2024 14:20:29 +0500 Subject: [PATCH 014/631] Issue resolved: LHN - RBR appears on the wrong workspace chat for an error occurring on another workspace #47874 --- src/libs/OptionsListUtils.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/libs/OptionsListUtils.ts b/src/libs/OptionsListUtils.ts index f191c1d06532..451727858654 100644 --- a/src/libs/OptionsListUtils.ts +++ b/src/libs/OptionsListUtils.ts @@ -471,7 +471,7 @@ function uniqFast(items: string[]): string[] { function getAllReportErrors(report: OnyxEntry, reportActions: OnyxEntry): OnyxCommon.Errors { const reportErrors = report?.errors ?? {}; const reportErrorFields = report?.errorFields ?? {}; - const reportActionsArray = Object.values(reportActions ?? {}); + const reportActionsArray = Object.values(reportActions ?? {}).filter(action => !ReportActionUtils.isDeletedAction(action)); const reportActionErrors: OnyxCommon.ErrorFields = {}; for (const action of reportActionsArray) { From af0bbb283d1ef35d5a9aa73888b77da4f86da908 Mon Sep 17 00:00:00 2001 From: daledah Date: Wed, 18 Sep 2024 12:07:26 +0700 Subject: [PATCH 015/631] fix: change undefined to null in onyx data --- src/libs/actions/TaxRate.ts | 4 ++-- src/types/onyx/Policy.ts | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/libs/actions/TaxRate.ts b/src/libs/actions/TaxRate.ts index c9073bd4a668..bbc63b60367b 100644 --- a/src/libs/actions/TaxRate.ts +++ b/src/libs/actions/TaxRate.ts @@ -308,8 +308,8 @@ function deletePolicyTaxes(policyID: string, taxesToDelete: string[]) { ...rate, attributes: { ...rate?.attributes, - taxRateExternalID: undefined, - taxClaimablePercentage: undefined, + taxRateExternalID: null, + taxClaimablePercentage: null, }, pendingFields: { taxRateExternalID: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, diff --git a/src/types/onyx/Policy.ts b/src/types/onyx/Policy.ts index f2604d723f05..c1c5e78126a0 100644 --- a/src/types/onyx/Policy.ts +++ b/src/types/onyx/Policy.ts @@ -11,10 +11,10 @@ type Unit = 'mi' | 'km'; /** Tax rate attributes of the policy distance rate */ type TaxRateAttributes = { /** Percentage of the tax that can be reclaimable */ - taxClaimablePercentage?: number; + taxClaimablePercentage?: number | null; /** External ID associated to this tax rate */ - taxRateExternalID?: string; + taxRateExternalID?: string | null; }; /** Model of policy distance rate */ From ddc6d7ac7bb0521110219b7d08041913e7b57e39 Mon Sep 17 00:00:00 2001 From: Ishpaul Singh Date: Thu, 19 Sep 2024 00:48:33 +0530 Subject: [PATCH 016/631] adds v1 changes --- .../MoneyRequestConfirmationList.tsx | 2 + src/libs/OptionsListUtils.ts | 53 +++++++++++++++++++ src/libs/ReportUtils.ts | 2 + src/libs/actions/Report.ts | 6 +++ 4 files changed, 63 insertions(+) diff --git a/src/components/MoneyRequestConfirmationList.tsx b/src/components/MoneyRequestConfirmationList.tsx index 743a5b276c98..5f9f3f35e8b6 100755 --- a/src/components/MoneyRequestConfirmationList.tsx +++ b/src/components/MoneyRequestConfirmationList.tsx @@ -654,6 +654,8 @@ function MoneyRequestConfirmationList({ isSelected: false, isInteractive: !shouldDisableParticipant(participant), })); + + // console.log('formattedSelectedParticipants', formattedSelectedParticipants); options.push({ title: translate('common.to'), data: formattedSelectedParticipants, diff --git a/src/libs/OptionsListUtils.ts b/src/libs/OptionsListUtils.ts index afedd308371c..ac78c3c75d48 100644 --- a/src/libs/OptionsListUtils.ts +++ b/src/libs/OptionsListUtils.ts @@ -10,6 +10,7 @@ import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; import type {SetNonNullable} from 'type-fest'; import {FallbackAvatar} from '@components/Icon/Expensicons'; import type {SelectedTagOption} from '@components/TagPicker'; +import {createDraftReportForPolicyExpenseChat} from '@libs/actions/Report'; import type {IOUAction} from '@src/CONST'; import CONST from '@src/CONST'; import type {TranslationPaths} from '@src/languages/types'; @@ -61,6 +62,7 @@ import * as UserUtils from './UserUtils'; type SearchOption = ReportUtils.OptionData & { item: T; + isOptimisticReportOption?: boolean; }; type OptionList = { @@ -239,6 +241,13 @@ Onyx.connect({ }, }); +let allReportsDraft: OnyxCollection; +Onyx.connect({ + key: ONYXKEYS.COLLECTION.REPORT_DRAFT, + waitForCollectionCallback: true, + callback: (value) => (allReportsDraft = value), +}); + let loginList: OnyxEntry; Onyx.connect({ key: ONYXKEYS.LOGIN_LIST, @@ -1516,6 +1525,7 @@ function isReportSelected(reportOption: ReportUtils.OptionData, selectedOptions: function createOptionList(personalDetails: OnyxEntry, reports?: OnyxCollection) { const reportMapForAccountIDs: Record = {}; const allReportOptions: Array> = []; + const policyToReportForPolicyExpenseChats: Record = {}; if (reports) { Object.values(reports).forEach((report) => { @@ -1531,6 +1541,10 @@ function createOptionList(personalDetails: OnyxEntry, repor return; } + if (ReportUtils.isPolicyExpenseChat(report) && report.policyID) { + policyToReportForPolicyExpenseChats[report.policyID] = report; + } + // Save the report in the map if this is a single participant so we can associate the reportID with the // personal detail option later. Individuals should not be associated with single participant // policyExpenseChats or chatRooms since those are not people. @@ -1545,6 +1559,45 @@ function createOptionList(personalDetails: OnyxEntry, repor }); } + const policiesWithoutExpenseChats = Object.values(policies ?? {}).filter((policy) => { + if (policy?.type === CONST.POLICY.TYPE.PERSONAL || !policy?.isPolicyExpenseChatEnabled) { + return false; + } + return !policyToReportForPolicyExpenseChats[policy?.id ?? '']; + }); + + // go through each policy and create a optimistic report option for it + if (policiesWithoutExpenseChats && policiesWithoutExpenseChats.length > 0) { + policiesWithoutExpenseChats.forEach((policy) => { + // check for draft report exist in allreportDrafts for the policy + let draftReport = Object.values(allReportsDraft ?? {})?.find((reportDraft) => reportDraft?.policyID === policy?.id); + if (!draftReport) { + draftReport = ReportUtils.buildOptimisticChatReport( + [currentUserAccountID ?? -1], + '', + CONST.REPORT.CHAT_TYPE.POLICY_EXPENSE_CHAT, + policy?.id, + currentUserAccountID, + true, + policy?.name, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + ); + createDraftReportForPolicyExpenseChat({...draftReport, isOptimisticReport: true}); + } + const accountIDs = ReportUtils.getParticipantsAccountIDsForDisplay(draftReport); + allReportOptions.push({ + item: draftReport, + ...createOption(accountIDs, personalDetails, draftReport, {}), + }); + }); + } const allPersonalDetailsOptions = Object.values(personalDetails ?? {}).map((personalDetail) => ({ item: personalDetail, ...createOption([personalDetail?.accountID ?? -1], personalDetails, reportMapForAccountIDs[personalDetail?.accountID ?? -1], {}, {showPersonalDetails: true}), diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index f37f3f940516..b408519e4c3a 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -267,6 +267,7 @@ type OptimisticChatReport = Pick< | 'chatReportID' | 'iouReportID' | 'isOwnPolicyExpenseChat' + | 'isPolicyExpenseChat' | 'isPinned' | 'lastActorAccountID' | 'lastMessageTranslationKey' @@ -5099,6 +5100,7 @@ function buildOptimisticChatReport( chatType, isOwnPolicyExpenseChat, isPinned: isNewlyCreatedWorkspaceChat, + isPolicyExpenseChat: chatType === CONST.REPORT.CHAT_TYPE.POLICY_EXPENSE_CHAT, lastActorAccountID: 0, lastMessageTranslationKey: '', lastMessageHtml: '', diff --git a/src/libs/actions/Report.ts b/src/libs/actions/Report.ts index caaf6840e56d..72b1a41646a6 100644 --- a/src/libs/actions/Report.ts +++ b/src/libs/actions/Report.ts @@ -76,6 +76,7 @@ import processReportIDDeeplink from '@libs/processReportIDDeeplink'; import * as Pusher from '@libs/Pusher/pusher'; import * as ReportActionsUtils from '@libs/ReportActionsUtils'; import * as ReportConnection from '@libs/ReportConnection'; +import type {OptimisticChatReport} from '@libs/ReportUtils'; import type {OptimisticAddCommentReportAction} from '@libs/ReportUtils'; import * as ReportUtils from '@libs/ReportUtils'; import {doesReportBelongToWorkspace} from '@libs/ReportUtils'; @@ -4097,6 +4098,10 @@ function markAsManuallyExported(reportID: string, connectionName: ConnectionName API.write(WRITE_COMMANDS.MARK_AS_EXPORTED, params, {optimisticData, successData, failureData}); } +function createDraftReportForPolicyExpenseChat(draftReport: OptimisticChatReport) { + Onyx.set(`${ONYXKEYS.COLLECTION.REPORT_DRAFT}${draftReport.reportID}`, draftReport); +} + export type {Video}; export { @@ -4185,4 +4190,5 @@ export { exportToIntegration, markAsManuallyExported, handleReportChanged, + createDraftReportForPolicyExpenseChat, }; From db274ef4410788b033cc48587e2c37fc07d4749f Mon Sep 17 00:00:00 2001 From: Ishpaul Singh Date: Thu, 19 Sep 2024 01:15:38 +0530 Subject: [PATCH 017/631] lint fix --- src/libs/OptionsListUtils.ts | 2 +- src/libs/actions/Report.ts | 3 +-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/src/libs/OptionsListUtils.ts b/src/libs/OptionsListUtils.ts index ac78c3c75d48..64b38e831d2c 100644 --- a/src/libs/OptionsListUtils.ts +++ b/src/libs/OptionsListUtils.ts @@ -10,7 +10,6 @@ import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; import type {SetNonNullable} from 'type-fest'; import {FallbackAvatar} from '@components/Icon/Expensicons'; import type {SelectedTagOption} from '@components/TagPicker'; -import {createDraftReportForPolicyExpenseChat} from '@libs/actions/Report'; import type {IOUAction} from '@src/CONST'; import CONST from '@src/CONST'; import type {TranslationPaths} from '@src/languages/types'; @@ -41,6 +40,7 @@ import type * as OnyxCommon from '@src/types/onyx/OnyxCommon'; import type DeepValueOf from '@src/types/utils/DeepValueOf'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; import times from '@src/utils/times'; +import {createDraftReportForPolicyExpenseChat} from './actions/Report'; import Timing from './actions/Timing'; import * as ErrorUtils from './ErrorUtils'; import filterArrayByMatch from './filterArrayByMatch'; diff --git a/src/libs/actions/Report.ts b/src/libs/actions/Report.ts index 72b1a41646a6..fb6cd7dc4168 100644 --- a/src/libs/actions/Report.ts +++ b/src/libs/actions/Report.ts @@ -76,8 +76,7 @@ import processReportIDDeeplink from '@libs/processReportIDDeeplink'; import * as Pusher from '@libs/Pusher/pusher'; import * as ReportActionsUtils from '@libs/ReportActionsUtils'; import * as ReportConnection from '@libs/ReportConnection'; -import type {OptimisticChatReport} from '@libs/ReportUtils'; -import type {OptimisticAddCommentReportAction} from '@libs/ReportUtils'; +import type {OptimisticAddCommentReportAction, OptimisticChatReport} from '@libs/ReportUtils'; import * as ReportUtils from '@libs/ReportUtils'; import {doesReportBelongToWorkspace} from '@libs/ReportUtils'; import shouldSkipDeepLinkNavigation from '@libs/shouldSkipDeepLinkNavigation'; From 2c3224b5d1a5cf6d9d0de6b4544edab193e73733 Mon Sep 17 00:00:00 2001 From: Ishpaul Singh Date: Thu, 19 Sep 2024 01:21:06 +0530 Subject: [PATCH 018/631] remove undesired changes --- src/components/MoneyRequestConfirmationList.tsx | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/components/MoneyRequestConfirmationList.tsx b/src/components/MoneyRequestConfirmationList.tsx index 5f9f3f35e8b6..743a5b276c98 100755 --- a/src/components/MoneyRequestConfirmationList.tsx +++ b/src/components/MoneyRequestConfirmationList.tsx @@ -654,8 +654,6 @@ function MoneyRequestConfirmationList({ isSelected: false, isInteractive: !shouldDisableParticipant(participant), })); - - // console.log('formattedSelectedParticipants', formattedSelectedParticipants); options.push({ title: translate('common.to'), data: formattedSelectedParticipants, From eee62507fabbbd9a4a6b794e2b8a20508ec34570 Mon Sep 17 00:00:00 2001 From: Ishpaul Singh Date: Thu, 19 Sep 2024 03:49:09 +0530 Subject: [PATCH 019/631] remove option from other lists --- src/libs/OptionsListUtils.ts | 8 ++++++++ .../iou/request/MoneyRequestParticipantsSelector.tsx | 1 + 2 files changed, 9 insertions(+) diff --git a/src/libs/OptionsListUtils.ts b/src/libs/OptionsListUtils.ts index 64b38e831d2c..45c659cf5c6d 100644 --- a/src/libs/OptionsListUtils.ts +++ b/src/libs/OptionsListUtils.ts @@ -181,6 +181,7 @@ type GetOptionsConfig = { includeDomainEmail?: boolean; action?: IOUAction; shouldBoldTitleByDefault?: boolean; + includePoliciesWithoutExpenseChats?: boolean; }; type GetUserToInviteConfig = { @@ -1594,6 +1595,7 @@ function createOptionList(personalDetails: OnyxEntry, repor const accountIDs = ReportUtils.getParticipantsAccountIDsForDisplay(draftReport); allReportOptions.push({ item: draftReport, + isOptimisticReportOption: true, ...createOption(accountIDs, personalDetails, draftReport, {}), }); }); @@ -1797,6 +1799,7 @@ function getOptions( includeDomainEmail = false, action, shouldBoldTitleByDefault = true, + includePoliciesWithoutExpenseChats = false, }: GetOptionsConfig, ): Options { if (includeCategories) { @@ -1861,6 +1864,9 @@ function getOptions( // Filter out all the reports that shouldn't be displayed const filteredReportOptions = options.reports.filter((option) => { + if (option.isOptimisticReportOption && !includePoliciesWithoutExpenseChats) { + return; + } const report = option.item; const doesReportHaveViolations = shouldShowViolations(report, transactionViolations); @@ -2191,6 +2197,7 @@ function getFilteredOptions( includeInvoiceRooms = false, action: IOUAction | undefined = undefined, sortByReportTypeInSearch = false, + includePoliciesWithoutExpenseChats = false, ) { return getOptions( {reports, personalDetails}, @@ -2220,6 +2227,7 @@ function getFilteredOptions( includeInvoiceRooms, action, sortByReportTypeInSearch, + includePoliciesWithoutExpenseChats, }, ); } diff --git a/src/pages/iou/request/MoneyRequestParticipantsSelector.tsx b/src/pages/iou/request/MoneyRequestParticipantsSelector.tsx index f10575f8c1d0..247b413da319 100644 --- a/src/pages/iou/request/MoneyRequestParticipantsSelector.tsx +++ b/src/pages/iou/request/MoneyRequestParticipantsSelector.tsx @@ -130,6 +130,7 @@ function MoneyRequestParticipantsSelector({participants = CONST.EMPTY_ARRAY, onF iouType === CONST.IOU.TYPE.INVOICE, action, isPaidGroupPolicy, + true, ); return optionList; From 6c5127e5fbd264f8dc9f10623ca2a72f2b2d7e17 Mon Sep 17 00:00:00 2001 From: daledah Date: Thu, 19 Sep 2024 14:19:12 +0700 Subject: [PATCH 020/631] fix: add missing key, refactor withOnyx --- src/libs/actions/TaxRate.ts | 2 +- .../PolicyDistanceRateDetailsPage.tsx | 20 +++++-------------- 2 files changed, 6 insertions(+), 16 deletions(-) diff --git a/src/libs/actions/TaxRate.ts b/src/libs/actions/TaxRate.ts index bbc63b60367b..439e5131508f 100644 --- a/src/libs/actions/TaxRate.ts +++ b/src/libs/actions/TaxRate.ts @@ -316,7 +316,7 @@ function deletePolicyTaxes(policyID: string, taxesToDelete: string[]) { taxClaimablePercentage: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, }, }; - successRates[rateID] = {...rate, pendingFields: {taxRateExternalID: null}}; + successRates[rateID] = {...rate, pendingFields: {taxRateExternalID: null, taxClaimablePercentage: null}}; failureRates[rateID] = { ...rate, pendingFields: {taxRateExternalID: null, taxClaimablePercentage: null}, diff --git a/src/pages/workspace/distanceRates/PolicyDistanceRateDetailsPage.tsx b/src/pages/workspace/distanceRates/PolicyDistanceRateDetailsPage.tsx index 982e4799d781..0b7d925f2ee2 100644 --- a/src/pages/workspace/distanceRates/PolicyDistanceRateDetailsPage.tsx +++ b/src/pages/workspace/distanceRates/PolicyDistanceRateDetailsPage.tsx @@ -1,8 +1,7 @@ import type {StackScreenProps} from '@react-navigation/stack'; import React, {useState} from 'react'; import {View} from 'react-native'; -import {withOnyx} from 'react-native-onyx'; -import type {OnyxEntry} from 'react-native-onyx'; +import {useOnyx} from 'react-native-onyx'; import ConfirmModal from '@components/ConfirmModal'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import * as Expensicons from '@components/Icon/Expensicons'; @@ -26,22 +25,17 @@ import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import type SCREENS from '@src/SCREENS'; -import type * as OnyxTypes from '@src/types/onyx'; import type {Rate, TaxRateAttributes} from '@src/types/onyx/Policy'; -type PolicyDistanceRateDetailsPageOnyxProps = { - /** Policy details */ - policy: OnyxEntry; -}; +type PolicyDistanceRateDetailsPageProps = StackScreenProps; -type PolicyDistanceRateDetailsPageProps = PolicyDistanceRateDetailsPageOnyxProps & StackScreenProps; - -function PolicyDistanceRateDetailsPage({policy, route}: PolicyDistanceRateDetailsPageProps) { +function PolicyDistanceRateDetailsPage({route}: PolicyDistanceRateDetailsPageProps) { const styles = useThemeStyles(); const {translate} = useLocalize(); const [isWarningModalVisible, setIsWarningModalVisible] = useState(false); const [isDeleteModalVisible, setIsDeleteModalVisible] = useState(false); const policyID = route.params.policyID; + const [policy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${route.params.policyID}`); const rateID = route.params.rateID; const customUnits = policy?.customUnits ?? {}; const customUnit = customUnits[Object.keys(customUnits)[0]]; @@ -209,8 +203,4 @@ function PolicyDistanceRateDetailsPage({policy, route}: PolicyDistanceRateDetail PolicyDistanceRateDetailsPage.displayName = 'PolicyDistanceRateDetailsPage'; -export default withOnyx({ - policy: { - key: ({route}) => `${ONYXKEYS.COLLECTION.POLICY}${route.params.policyID}`, - }, -})(PolicyDistanceRateDetailsPage); +export default PolicyDistanceRateDetailsPage; From 14ffa198bab8d0d095a53eb04faab5ac0e3e0309 Mon Sep 17 00:00:00 2001 From: Getabalew Date: Thu, 19 Sep 2024 11:37:23 +0300 Subject: [PATCH 021/631] refactor: reuse ValidateCodeActionModal --- src/ROUTES.ts | 1 - src/SCREENS.ts | 1 - .../ValidateCodeForm/BaseValidateCodeForm.tsx | 15 +- .../ValidateCodeActionModal/index.tsx | 20 +- .../ValidateCodeActionModal/type.ts | 8 + .../ModalStackNavigators/index.tsx | 1 - .../CENTRAL_PANE_TO_RHP_MAPPING.ts | 1 - src/libs/Navigation/linkingConfig/config.ts | 3 - src/libs/actions/Delegate.ts | 9 +- .../Contacts/ContactMethodDetailsPage.tsx | 185 ++++++++-------- .../Profile/Contacts/ContactMethodsPage.tsx | 7 +- .../Profile/Contacts/NewContactMethodPage.tsx | 1 + .../Contacts/ValidateContactActionPage.tsx | 72 ------ .../AddDelegate/ConfirmDelegatePage.tsx | 6 +- .../AddDelegate/DelegateMagicCodePage.tsx | 58 +++-- .../ValidateCodeForm/BaseValidateCodeForm.tsx | 208 ------------------ .../ValidateCodeForm/index.android.tsx | 14 -- .../AddDelegate/ValidateCodeForm/index.tsx | 14 -- .../settings/Wallet/ExpensifyCardPage.tsx | 2 + 19 files changed, 163 insertions(+), 463 deletions(-) delete mode 100644 src/pages/settings/Profile/Contacts/ValidateContactActionPage.tsx delete mode 100644 src/pages/settings/Security/AddDelegate/ValidateCodeForm/BaseValidateCodeForm.tsx delete mode 100644 src/pages/settings/Security/AddDelegate/ValidateCodeForm/index.android.tsx delete mode 100644 src/pages/settings/Security/AddDelegate/ValidateCodeForm/index.tsx diff --git a/src/ROUTES.ts b/src/ROUTES.ts index 27504998c49c..c9116f337f9e 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -217,7 +217,6 @@ const ROUTES = { route: 'settings/profile/contact-methods/:contactMethod/details', getRoute: (contactMethod: string, backTo?: string) => getUrlWithBackToParam(`settings/profile/contact-methods/${encodeURIComponent(contactMethod)}/details`, backTo), }, - SETINGS_CONTACT_METHOD_VALIDATE_ACTION: 'settings/profile/contact-methods/validate-action', SETTINGS_NEW_CONTACT_METHOD: { route: 'settings/profile/contact-methods/new', getRoute: (backTo?: string) => getUrlWithBackToParam('settings/profile/contact-methods/new', backTo), diff --git a/src/SCREENS.ts b/src/SCREENS.ts index 8168afba89ab..66cc2b420f44 100644 --- a/src/SCREENS.ts +++ b/src/SCREENS.ts @@ -74,7 +74,6 @@ const SCREENS = { DISPLAY_NAME: 'Settings_Display_Name', CONTACT_METHODS: 'Settings_ContactMethods', CONTACT_METHOD_DETAILS: 'Settings_ContactMethodDetails', - CONTACT_METHOD_VALIDATE_ACTION: 'Settings_ValidateContactMethodAction', NEW_CONTACT_METHOD: 'Settings_NewContactMethod', STATUS_CLEAR_AFTER: 'Settings_Status_Clear_After', STATUS_CLEAR_AFTER_DATE: 'Settings_Status_Clear_After_Date', diff --git a/src/components/ValidateCodeActionModal/ValidateCodeForm/BaseValidateCodeForm.tsx b/src/components/ValidateCodeActionModal/ValidateCodeForm/BaseValidateCodeForm.tsx index 247c0c606901..f6df07278ad8 100644 --- a/src/components/ValidateCodeActionModal/ValidateCodeForm/BaseValidateCodeForm.tsx +++ b/src/components/ValidateCodeActionModal/ValidateCodeForm/BaseValidateCodeForm.tsx @@ -64,6 +64,8 @@ type ValidateCodeFormProps = { /** Function to clear error of the form */ clearError: () => void; + + sendValidateCode: () => void; }; type BaseValidateCodeFormProps = BaseValidateCodeFormOnyxProps & ValidateCodeFormProps; @@ -78,6 +80,7 @@ function BaseValidateCodeForm({ validateError, handleSubmitForm, clearError, + sendValidateCode, }: BaseValidateCodeFormProps) { const {translate} = useLocalize(); const {isOffline} = useNetwork(); @@ -128,14 +131,6 @@ function BaseValidateCodeForm({ }, []), ); - useEffect(() => { - if (!validateError) { - return; - } - clearError(); - // eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps - }, [clearError, validateError]); - useEffect(() => { if (!hasMagicCodeBeenSent) { return; @@ -147,7 +142,7 @@ function BaseValidateCodeForm({ * Request a validate code / magic code be sent to verify this contact method */ const resendValidateCode = () => { - User.requestValidateCodeAction(); + sendValidateCode(); inputValidateCodeRef.current?.clear(); }; @@ -196,7 +191,7 @@ function BaseValidateCodeForm({ errorText={formError?.validateCode ? translate(formError?.validateCode) : ErrorUtils.getLatestErrorMessage(account ?? {})} hasError={!isEmptyObject(validateError)} onFulfill={validateAndSubmitForm} - autoFocus={false} + autoFocus /> (null); @@ -30,7 +42,8 @@ function ValidateCodeActionModal({isVisible, title, description, onClose, valida return; } firstRenderRef.current = false; - User.requestValidateCodeAction(); + + sendValidateCode(); }, [isVisible]); return ( @@ -61,10 +74,13 @@ function ValidateCodeActionModal({isVisible, title, description, onClose, valida validatePendingAction={validatePendingAction} validateError={validateError} handleSubmitForm={handleSubmitForm} + sendValidateCode={sendValidateCode} clearError={clearError} ref={validateCodeFormRef} + hasMagicCodeBeenSent={hasMagicCodeBeenSent} /> + {footer} ); diff --git a/src/components/ValidateCodeActionModal/type.ts b/src/components/ValidateCodeActionModal/type.ts index 3cbfe62513d1..821f54ff0302 100644 --- a/src/components/ValidateCodeActionModal/type.ts +++ b/src/components/ValidateCodeActionModal/type.ts @@ -1,3 +1,4 @@ +import React from 'react'; import type {Errors, PendingAction} from '@src/types/onyx/OnyxCommon'; type ValidateCodeActionModalProps = { @@ -24,6 +25,13 @@ type ValidateCodeActionModalProps = { /** Function to clear error of the form */ clearError: () => void; + + footer?: React.JSX.Element; + + sendValidateCode: () => void; + + /** If the magic code has been resent previously */ + hasMagicCodeBeenSent?: boolean; }; // eslint-disable-next-line import/prefer-default-export diff --git a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx index b41b58530a6b..b24c6b3ea4f6 100644 --- a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx +++ b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx @@ -190,7 +190,6 @@ const SettingsModalStackNavigator = createModalStackNavigator require('../../../../pages/settings/Profile/PersonalDetails/StateSelectionPage').default, [SCREENS.SETTINGS.PROFILE.CONTACT_METHODS]: () => require('../../../../pages/settings/Profile/Contacts/ContactMethodsPage').default, [SCREENS.SETTINGS.PROFILE.CONTACT_METHOD_DETAILS]: () => require('../../../../pages/settings/Profile/Contacts/ContactMethodDetailsPage').default, - [SCREENS.SETTINGS.PROFILE.CONTACT_METHOD_VALIDATE_ACTION]: () => require('../../../../pages/settings/Profile/Contacts/ValidateContactActionPage').default, [SCREENS.SETTINGS.PROFILE.NEW_CONTACT_METHOD]: () => require('../../../../pages/settings/Profile/Contacts/NewContactMethodPage').default, [SCREENS.SETTINGS.PREFERENCES.PRIORITY_MODE]: () => require('../../../../pages/settings/Preferences/PriorityModePage').default, [SCREENS.WORKSPACE.ACCOUNTING.ROOT]: () => require('../../../../pages/workspace/accounting/PolicyAccountingPage').default, diff --git a/src/libs/Navigation/linkingConfig/CENTRAL_PANE_TO_RHP_MAPPING.ts b/src/libs/Navigation/linkingConfig/CENTRAL_PANE_TO_RHP_MAPPING.ts index 609162bedd13..3dc91a1bb530 100755 --- a/src/libs/Navigation/linkingConfig/CENTRAL_PANE_TO_RHP_MAPPING.ts +++ b/src/libs/Navigation/linkingConfig/CENTRAL_PANE_TO_RHP_MAPPING.ts @@ -6,7 +6,6 @@ const CENTRAL_PANE_TO_RHP_MAPPING: Partial> = SCREENS.SETTINGS.PROFILE.DISPLAY_NAME, SCREENS.SETTINGS.PROFILE.CONTACT_METHODS, SCREENS.SETTINGS.PROFILE.CONTACT_METHOD_DETAILS, - SCREENS.SETTINGS.PROFILE.CONTACT_METHOD_VALIDATE_ACTION, SCREENS.SETTINGS.PROFILE.NEW_CONTACT_METHOD, SCREENS.SETTINGS.PROFILE.STATUS_CLEAR_AFTER, SCREENS.SETTINGS.PROFILE.STATUS_CLEAR_AFTER_DATE, diff --git a/src/libs/Navigation/linkingConfig/config.ts b/src/libs/Navigation/linkingConfig/config.ts index 2ca2db10a1a7..abfa9625926f 100644 --- a/src/libs/Navigation/linkingConfig/config.ts +++ b/src/libs/Navigation/linkingConfig/config.ts @@ -249,9 +249,6 @@ const config: LinkingOptions['config'] = { [SCREENS.SETTINGS.PROFILE.CONTACT_METHOD_DETAILS]: { path: ROUTES.SETTINGS_CONTACT_METHOD_DETAILS.route, }, - [SCREENS.SETTINGS.PROFILE.CONTACT_METHOD_VALIDATE_ACTION]: { - path: ROUTES.SETINGS_CONTACT_METHOD_VALIDATE_ACTION, - }, [SCREENS.SETTINGS.PROFILE.NEW_CONTACT_METHOD]: { path: ROUTES.SETTINGS_NEW_CONTACT_METHOD.route, exact: true, diff --git a/src/libs/actions/Delegate.ts b/src/libs/actions/Delegate.ts index 50d2ee7fc194..54165c4afa62 100644 --- a/src/libs/actions/Delegate.ts +++ b/src/libs/actions/Delegate.ts @@ -161,10 +161,6 @@ function clearDelegatorErrors() { Onyx.merge(ONYXKEYS.ACCOUNT, {delegatedAccess: {delegators: delegatedAccess.delegators.map((delegator) => ({...delegator, errorFields: undefined}))}}); } -function requestValidationCode() { - API.write(WRITE_COMMANDS.RESEND_VALIDATE_CODE, null); -} - function addDelegate(email: string, role: DelegateRole, validateCode: string) { const existingDelegate = delegatedAccess?.delegates?.find((delegate) => delegate.email === email); @@ -206,6 +202,7 @@ function addDelegate(email: string, role: DelegateRole, validateCode: string) { delegatedAccess: { delegates: optimisticDelegateData(), }, + isLoading: true, }, }, ]; @@ -250,6 +247,7 @@ function addDelegate(email: string, role: DelegateRole, validateCode: string) { delegatedAccess: { delegates: successDelegateData(), }, + isLoading: false, }, }, ]; @@ -292,6 +290,7 @@ function addDelegate(email: string, role: DelegateRole, validateCode: string) { delegatedAccess: { delegates: failureDelegateData(), }, + isLoading: false, }, }, ]; @@ -325,4 +324,4 @@ function removePendingDelegate(email: string) { }); } -export {connect, disconnect, clearDelegatorErrors, addDelegate, requestValidationCode, clearAddDelegateErrors, removePendingDelegate}; +export {connect, disconnect, clearDelegatorErrors, addDelegate, clearAddDelegateErrors, removePendingDelegate}; diff --git a/src/pages/settings/Profile/Contacts/ContactMethodDetailsPage.tsx b/src/pages/settings/Profile/Contacts/ContactMethodDetailsPage.tsx index 9fcc28f51912..e4751ebb0293 100644 --- a/src/pages/settings/Profile/Contacts/ContactMethodDetailsPage.tsx +++ b/src/pages/settings/Profile/Contacts/ContactMethodDetailsPage.tsx @@ -15,6 +15,7 @@ import OfflineWithFeedback from '@components/OfflineWithFeedback'; import ScreenWrapper from '@components/ScreenWrapper'; import ScrollView from '@components/ScrollView'; import Text from '@components/Text'; +import ValidateCodeActionModal from '@components/ValidateCodeActionModal'; import useLocalize from '@hooks/useLocalize'; import usePrevious from '@hooks/usePrevious'; import useTheme from '@hooks/useTheme'; @@ -25,6 +26,7 @@ import Navigation from '@libs/Navigation/Navigation'; import type {SettingsNavigatorParamList} from '@libs/Navigation/types'; import * as User from '@userActions/User'; import CONST from '@src/CONST'; +import {TranslationPaths} from '@src/languages/types'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import type SCREENS from '@src/SCREENS'; @@ -35,12 +37,17 @@ import type {ValidateCodeFormHandle} from './ValidateCodeForm/BaseValidateCodeFo type ContactMethodDetailsPageProps = StackScreenProps; +type ValidateCodeFormError = { + validateCode?: TranslationPaths; +}; + function ContactMethodDetailsPage({route}: ContactMethodDetailsPageProps) { const [loginList, loginListResult] = useOnyx(ONYXKEYS.LOGIN_LIST); const [session, sessionResult] = useOnyx(ONYXKEYS.SESSION); const [myDomainSecurityGroups, myDomainSecurityGroupsResult] = useOnyx(ONYXKEYS.MY_DOMAIN_SECURITY_GROUPS); const [securityGroups, securityGroupsResult] = useOnyx(ONYXKEYS.COLLECTION.SECURITY_GROUP); const [isLoadingReportData, isLoadingReportDataResult] = useOnyx(ONYXKEYS.IS_LOADING_REPORT_DATA, {initialValue: true}); + const [isValidateCodeActionModalVisible, setIsValidateCodeActionModalVisible] = useState(true); const isLoadingOnyxValues = isLoadingOnyxValue(loginListResult, sessionResult, myDomainSecurityGroupsResult, securityGroupsResult, isLoadingReportDataResult); @@ -75,6 +82,7 @@ function ContactMethodDetailsPage({route}: ContactMethodDetailsPageProps) { }, [route.params.contactMethod]); const loginData = useMemo(() => loginList?.[contactMethod], [loginList, contactMethod]); const isDefaultContactMethod = useMemo(() => session?.email === loginData?.partnerUserID, [session?.email, loginData?.partnerUserID]); + const validateLoginError = ErrorUtils.getEarliestErrorField(loginData, 'validateLogin'); /** * Attempt to set this contact method as user's "Default contact method" @@ -145,6 +153,10 @@ function ContactMethodDetailsPage({route}: ContactMethodDetailsPageProps) { Navigation.goBack(ROUTES.SETTINGS_CONTACT_METHODS.getRoute(backTo)); }, [prevValidatedDate, loginData?.validatedDate, isDefaultContactMethod, backTo]); + useEffect(() => { + setIsValidateCodeActionModalVisible(!loginData?.validatedDate && !loginData?.errorFields?.addedLogin); + }, [loginData?.validatedDate, loginData?.errorFields?.addedLogin]); + if (isLoadingOnyxValues || (isLoadingReportData && isEmptyObject(loginList))) { return ; } @@ -168,100 +180,97 @@ function ContactMethodDetailsPage({route}: ContactMethodDetailsPageProps) { const isFailedAddContactMethod = !!loginData.errorFields?.addedLogin; const isFailedRemovedContactMethod = !!loginData.errorFields?.deletedLogin; + const MenuItems = () => ( + <> + {canChangeDefaultContactMethod ? ( + User.clearContactMethodErrors(contactMethod, 'defaultLogin')} + > + + + ) : null} + {isDefaultContactMethod ? ( + User.clearContactMethodErrors(contactMethod, isFailedRemovedContactMethod ? 'deletedLogin' : 'defaultLogin')} + > + {translate('contacts.yourDefaultContactMethod')} + + ) : ( + User.clearContactMethodErrors(contactMethod, 'deletedLogin')} + > + toggleDeleteModal(true)} + /> + + )} + + ); + return ( - validateCodeFormRef.current?.focus?.()} - testID={ContactMethodDetailsPage.displayName} - > - Navigation.goBack(ROUTES.SETTINGS_CONTACT_METHODS.getRoute(backTo))} + + toggleDeleteModal(false)} + onModalHide={() => { + InteractionManager.runAfterInteractions(() => { + validateCodeFormRef.current?.focusLastSelected?.(); + }); + }} + prompt={translate('contacts.removeAreYouSure')} + confirmText={translate('common.yesContinue')} + cancelText={translate('common.cancel')} + isVisible={isDeleteModalOpen && !isDefaultContactMethod} + danger /> - - toggleDeleteModal(false)} - onModalHide={() => { - InteractionManager.runAfterInteractions(() => { - validateCodeFormRef.current?.focusLastSelected?.(); - }); + + {isFailedAddContactMethod && ( + { + User.clearContactMethod(contactMethod); + Navigation.goBack(ROUTES.SETTINGS_CONTACT_METHODS.getRoute(backTo)); }} - prompt={translate('contacts.removeAreYouSure')} - confirmText={translate('common.yesContinue')} - cancelText={translate('common.cancel')} - isVisible={isDeleteModalOpen && !isDefaultContactMethod} - danger + canDismissError /> + )} - {isFailedAddContactMethod && ( - { - User.clearContactMethod(contactMethod); - Navigation.goBack(ROUTES.SETTINGS_CONTACT_METHODS.getRoute(backTo)); - }} - canDismissError - /> - )} - - {!loginData.validatedDate && !isFailedAddContactMethod && ( - - + User.validateSecondaryLogin(loginList, contactMethod, validateCode)} + validateError={!isEmptyObject(validateLoginError) ? validateLoginError : ErrorUtils.getLatestErrorField(loginData, 'validateCodeSent')} + clearError={() => User.clearContactMethodErrors(contactMethod, !isEmptyObject(validateLoginError) ? 'validateLogin' : 'validateCodeSent')} + onClose={() => { + Navigation.goBack(ROUTES.SETTINGS_CONTACT_METHODS.getRoute(backTo)); + setIsValidateCodeActionModalVisible(false); + }} + sendValidateCode={() => User.requestContactMethodValidateCode(contactMethod)} + description={translate('contacts.enterMagicCode', {contactMethod})} + footer={} + /> - - - )} - {canChangeDefaultContactMethod ? ( - User.clearContactMethodErrors(contactMethod, 'defaultLogin')} - > - - - ) : null} - {isDefaultContactMethod ? ( - User.clearContactMethodErrors(contactMethod, isFailedRemovedContactMethod ? 'deletedLogin' : 'defaultLogin')} - > - {translate('contacts.yourDefaultContactMethod')} - - ) : ( - User.clearContactMethodErrors(contactMethod, 'deletedLogin')} - > - toggleDeleteModal(true)} - /> - - )} - - + {!isValidateCodeActionModalVisible && } + ); } diff --git a/src/pages/settings/Profile/Contacts/ContactMethodsPage.tsx b/src/pages/settings/Profile/Contacts/ContactMethodsPage.tsx index 3f23b3a802be..cbe44ea648ca 100644 --- a/src/pages/settings/Profile/Contacts/ContactMethodsPage.tsx +++ b/src/pages/settings/Profile/Contacts/ContactMethodsPage.tsx @@ -81,12 +81,7 @@ function ContactMethodsPage({loginList, session, route}: ContactMethodsPageProps { - if (!login?.validatedDate && !login?.validateCodeSent) { - User.requestContactMethodValidateCode(loginName); - } - Navigation.navigate(ROUTES.SETTINGS_CONTACT_METHOD_DETAILS.getRoute(partnerUserID, navigateBackTo)); - }} + onPress={() => Navigation.navigate(ROUTES.SETTINGS_CONTACT_METHOD_DETAILS.getRoute(partnerUserID, navigateBackTo))} brickRoadIndicator={indicator} shouldShowBasicTitle shouldShowRightIcon diff --git a/src/pages/settings/Profile/Contacts/NewContactMethodPage.tsx b/src/pages/settings/Profile/Contacts/NewContactMethodPage.tsx index 4ea878e82987..6824b5988a62 100644 --- a/src/pages/settings/Profile/Contacts/NewContactMethodPage.tsx +++ b/src/pages/settings/Profile/Contacts/NewContactMethodPage.tsx @@ -153,6 +153,7 @@ function NewContactMethodPage({loginList, route}: NewContactMethodPageProps) { onClose={() => setIsValidateCodeActionModalVisible(false)} isVisible={isValidateCodeActionModalVisible} title={contactMethod} + sendValidateCode={() => User.requestValidateCodeAction()} description={translate('contacts.enterMagicCode', {contactMethod})} /> diff --git a/src/pages/settings/Profile/Contacts/ValidateContactActionPage.tsx b/src/pages/settings/Profile/Contacts/ValidateContactActionPage.tsx deleted file mode 100644 index 157588a67397..000000000000 --- a/src/pages/settings/Profile/Contacts/ValidateContactActionPage.tsx +++ /dev/null @@ -1,72 +0,0 @@ -import React, {useEffect, useRef} from 'react'; -import {View} from 'react-native'; -import {useOnyx} from 'react-native-onyx'; -import DotIndicatorMessage from '@components/DotIndicatorMessage'; -import HeaderWithBackButton from '@components/HeaderWithBackButton'; -import ScreenWrapper from '@components/ScreenWrapper'; -import useLocalize from '@hooks/useLocalize'; -import useThemeStyles from '@hooks/useThemeStyles'; -import * as User from '@libs/actions/User'; -import Navigation from '@libs/Navigation/Navigation'; -import ONYXKEYS from '@src/ONYXKEYS'; -import ROUTES from '@src/ROUTES'; -import ValidateCodeForm from './ValidateCodeForm'; -import type {ValidateCodeFormHandle} from './ValidateCodeForm/BaseValidateCodeForm'; - -function ValidateContactActionPage() { - const [account] = useOnyx(ONYXKEYS.ACCOUNT); - const themeStyles = useThemeStyles(); - const {translate} = useLocalize(); - const validateCodeFormRef = useRef(null); - const [loginList] = useOnyx(ONYXKEYS.LOGIN_LIST); - - const [pendingContactAction] = useOnyx(ONYXKEYS.PENDING_CONTACT_ACTION); - const loginData = loginList?.[pendingContactAction?.contactMethod ?? '']; - - useEffect(() => { - if (!loginData || !!loginData.pendingFields?.addedLogin) { - return; - } - - // Navigate to methods page on successful magic code verification - Navigation.navigate(ROUTES.SETTINGS_CONTACT_METHODS.route); - }, [loginData, loginData?.pendingFields, loginList]); - - const onBackButtonPress = () => { - User.clearUnvalidatedNewContactMethodAction(); - Navigation.goBack(ROUTES.SETTINGS_CONTACT_METHODS.route); - }; - - return ( - - - - - - - - ); -} - -ValidateContactActionPage.displayName = 'ValidateContactActionPage'; - -export default ValidateContactActionPage; diff --git a/src/pages/settings/Security/AddDelegate/ConfirmDelegatePage.tsx b/src/pages/settings/Security/AddDelegate/ConfirmDelegatePage.tsx index 8c8292b1f320..f54c43b73726 100644 --- a/src/pages/settings/Security/AddDelegate/ConfirmDelegatePage.tsx +++ b/src/pages/settings/Security/AddDelegate/ConfirmDelegatePage.tsx @@ -10,7 +10,6 @@ import Text from '@components/Text'; import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; import useThemeStyles from '@hooks/useThemeStyles'; -import {requestValidationCode} from '@libs/actions/Delegate'; import {formatPhoneNumber} from '@libs/LocalePhoneNumber'; import Navigation from '@libs/Navigation/Navigation'; import type {SettingsNavigatorParamList} from '@libs/Navigation/types'; @@ -43,10 +42,7 @@ function ConfirmDelegatePage({route}: ConfirmDelegatePageProps) { text={translate('delegate.addCopilot')} style={styles.mt6} pressOnEnter - onPress={() => { - requestValidationCode(); - Navigation.navigate(ROUTES.SETTINGS_DELEGATE_MAGIC_CODE.getRoute(login, role)); - }} + onPress={() => Navigation.navigate(ROUTES.SETTINGS_DELEGATE_MAGIC_CODE.getRoute(login, role))} /> ); diff --git a/src/pages/settings/Security/AddDelegate/DelegateMagicCodePage.tsx b/src/pages/settings/Security/AddDelegate/DelegateMagicCodePage.tsx index 9497507f041a..603fa1e5aa02 100644 --- a/src/pages/settings/Security/AddDelegate/DelegateMagicCodePage.tsx +++ b/src/pages/settings/Security/AddDelegate/DelegateMagicCodePage.tsx @@ -1,33 +1,31 @@ import type {StackScreenProps} from '@react-navigation/stack'; -import React, {useEffect, useRef} from 'react'; +import React, {useEffect, useState} from 'react'; import {useOnyx} from 'react-native-onyx'; import type {ValueOf} from 'type-fest'; -import HeaderWithBackButton from '@components/HeaderWithBackButton'; -import ScreenWrapper from '@components/ScreenWrapper'; -import Text from '@components/Text'; +import ValidateCodeActionModal from '@components/ValidateCodeActionModal'; import useLocalize from '@hooks/useLocalize'; -import useThemeStyles from '@hooks/useThemeStyles'; +import * as User from '@libs/actions/User'; +import * as ErrorUtils from '@libs/ErrorUtils'; import Navigation from '@libs/Navigation/Navigation'; import type {SettingsNavigatorParamList} from '@libs/Navigation/types'; +import * as Delegate from '@userActions/Delegate'; import type CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import type SCREENS from '@src/SCREENS'; -import ValidateCodeForm from './ValidateCodeForm'; -import type {ValidateCodeFormHandle} from './ValidateCodeForm/BaseValidateCodeForm'; type DelegateMagicCodePageProps = StackScreenProps; function DelegateMagicCodePage({route}: DelegateMagicCodePageProps) { const {translate} = useLocalize(); const [account] = useOnyx(ONYXKEYS.ACCOUNT); + const [isValidateCodeActionModalVisible, setIsValidateCodeActionModalVisible] = useState(true); + const login = route.params.login; const role = route.params.role as ValueOf; - const styles = useThemeStyles(); - const validateCodeFormRef = useRef(null); - const currentDelegate = account?.delegatedAccess?.delegates?.find((d) => d.email === login); + const validateLoginError = ErrorUtils.getLatestErrorField(currentDelegate, 'addDelegate'); useEffect(() => { if (!currentDelegate || !!currentDelegate.pendingFields?.email || !!currentDelegate.errorFields?.addDelegate) { @@ -39,32 +37,28 @@ function DelegateMagicCodePage({route}: DelegateMagicCodePageProps) { }, [login, currentDelegate, role]); const onBackButtonPress = () => { + setIsValidateCodeActionModalVisible(false); Navigation.goBack(ROUTES.SETTINGS_DELEGATE_CONFIRM.getRoute(login, role)); }; + const clearError = () => { + if (!validateLoginError) { + return; + } + Delegate.clearAddDelegateErrors(currentDelegate?.email ?? '', 'addDelegate'); + }; + return ( - - {({safeAreaPaddingBottomStyle}) => ( - <> - - {translate('delegate.enterMagicCode', {contactMethod: account?.primaryLogin ?? ''})} - - - )} - + User.requestValidateCodeAction()} + handleSubmitForm={(validateCode) => Delegate.addDelegate(login, role, validateCode)} + description={translate('delegate.enterMagicCode', {contactMethod: account?.primaryLogin ?? ''})} + /> ); } diff --git a/src/pages/settings/Security/AddDelegate/ValidateCodeForm/BaseValidateCodeForm.tsx b/src/pages/settings/Security/AddDelegate/ValidateCodeForm/BaseValidateCodeForm.tsx deleted file mode 100644 index c9816862ad35..000000000000 --- a/src/pages/settings/Security/AddDelegate/ValidateCodeForm/BaseValidateCodeForm.tsx +++ /dev/null @@ -1,208 +0,0 @@ -import {useFocusEffect} from '@react-navigation/native'; -import type {ForwardedRef} from 'react'; -import React, {useCallback, useImperativeHandle, useRef, useState} from 'react'; -import {View} from 'react-native'; -import type {StyleProp, ViewStyle} from 'react-native'; -import {useOnyx} from 'react-native-onyx'; -import Button from '@components/Button'; -import FixedFooter from '@components/FixedFooter'; -import MagicCodeInput from '@components/MagicCodeInput'; -import type {AutoCompleteVariant, MagicCodeInputHandle} from '@components/MagicCodeInput'; -import OfflineWithFeedback from '@components/OfflineWithFeedback'; -import PressableWithFeedback from '@components/Pressable/PressableWithFeedback'; -import Text from '@components/Text'; -import useLocalize from '@hooks/useLocalize'; -import useNetwork from '@hooks/useNetwork'; -import useStyleUtils from '@hooks/useStyleUtils'; -import useTheme from '@hooks/useTheme'; -import useThemeStyles from '@hooks/useThemeStyles'; -import * as ErrorUtils from '@libs/ErrorUtils'; -import * as ValidationUtils from '@libs/ValidationUtils'; -import * as Delegate from '@userActions/Delegate'; -import CONST from '@src/CONST'; -import type {TranslationPaths} from '@src/languages/types'; -import ONYXKEYS from '@src/ONYXKEYS'; -import type {DelegateRole} from '@src/types/onyx/Account'; -import {isEmptyObject} from '@src/types/utils/EmptyObject'; - -type ValidateCodeFormHandle = { - focus: () => void; - focusLastSelected: () => void; -}; - -type ValidateCodeFormError = { - validateCode?: TranslationPaths; -}; - -type BaseValidateCodeFormProps = { - /** Specifies autocomplete hints for the system, so it can provide autofill */ - autoComplete?: AutoCompleteVariant; - - /** Forwarded inner ref */ - innerRef?: ForwardedRef; - - /** The email of the delegate */ - delegate: string; - - /** The role of the delegate */ - role: DelegateRole; - - /** Any additional styles to apply */ - wrapperStyle?: StyleProp; -}; - -function BaseValidateCodeForm({autoComplete = 'one-time-code', innerRef = () => {}, delegate, role, wrapperStyle}: BaseValidateCodeFormProps) { - const {translate} = useLocalize(); - const {isOffline} = useNetwork(); - const theme = useTheme(); - const styles = useThemeStyles(); - const StyleUtils = useStyleUtils(); - const [formError, setFormError] = useState({}); - const [validateCode, setValidateCode] = useState(''); - const inputValidateCodeRef = useRef(null); - const [account] = useOnyx(ONYXKEYS.ACCOUNT); - const login = account?.primaryLogin; - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing -- nullish coalescing doesn't achieve the same result in this case - const focusTimeoutRef = useRef(null); - - const currentDelegate = account?.delegatedAccess?.delegates?.find((d) => d.email === delegate); - const validateLoginError = ErrorUtils.getLatestErrorField(currentDelegate, 'addDelegate'); - - const shouldDisableResendValidateCode = !!isOffline || currentDelegate?.isLoading; - - useImperativeHandle(innerRef, () => ({ - focus() { - inputValidateCodeRef.current?.focus(); - }, - focusLastSelected() { - if (!inputValidateCodeRef.current) { - return; - } - if (focusTimeoutRef.current) { - clearTimeout(focusTimeoutRef.current); - } - focusTimeoutRef.current = setTimeout(() => { - inputValidateCodeRef.current?.focusLastSelected(); - }, CONST.ANIMATED_TRANSITION); - }, - })); - - useFocusEffect( - useCallback(() => { - if (!inputValidateCodeRef.current) { - return; - } - if (focusTimeoutRef.current) { - clearTimeout(focusTimeoutRef.current); - } - focusTimeoutRef.current = setTimeout(() => { - inputValidateCodeRef.current?.focusLastSelected(); - }, CONST.ANIMATED_TRANSITION); - return () => { - if (!focusTimeoutRef.current) { - return; - } - clearTimeout(focusTimeoutRef.current); - }; - }, []), - ); - - /** - * Request a validate code / magic code be sent to verify this contact method - */ - const resendValidateCode = () => { - if (!login) { - return; - } - Delegate.requestValidationCode(); - - inputValidateCodeRef.current?.clear(); - }; - - /** - * Handle text input and clear formError upon text change - */ - const onTextInput = useCallback( - (text: string) => { - setValidateCode(text); - setFormError({}); - if (validateLoginError) { - Delegate.clearAddDelegateErrors(currentDelegate?.email ?? '', 'addDelegate'); - } - }, - [currentDelegate?.email, validateLoginError], - ); - - /** - * Check that all the form fields are valid, then trigger the submit callback - */ - const validateAndSubmitForm = useCallback(() => { - if (!validateCode.trim()) { - setFormError({validateCode: 'validateCodeForm.error.pleaseFillMagicCode'}); - return; - } - - if (!ValidationUtils.isValidValidateCode(validateCode)) { - setFormError({validateCode: 'validateCodeForm.error.incorrectMagicCode'}); - return; - } - - setFormError({}); - - Delegate.addDelegate(delegate, role, validateCode); - }, [delegate, role, validateCode]); - - return ( - - - - - - - {translate('validateCodeForm.magicCodeNotReceived')} - - - - - - -