From 55ae17f06845dd5ce143308c8b7e8381c334de90 Mon Sep 17 00:00:00 2001 From: Bernhard Owen Josephus Date: Fri, 6 Dec 2024 23:56:56 +0800 Subject: [PATCH 01/70] fix emoji only is cut off is wrapped with markdown and contains whitespaces --- .../HTMLEngineProvider/BaseHTMLEngineProvider.tsx | 14 ++++++++++++-- .../HTMLRenderers/EditedRenderer.tsx | 3 +-- .../home/report/comment/RenderCommentHTML.tsx | 6 ++++-- .../home/report/comment/TextCommentFragment.tsx | 4 ++-- 4 files changed, 19 insertions(+), 8 deletions(-) diff --git a/src/components/HTMLEngineProvider/BaseHTMLEngineProvider.tsx b/src/components/HTMLEngineProvider/BaseHTMLEngineProvider.tsx index d211aac7fd4c..bf69fe4dfbdb 100755 --- a/src/components/HTMLEngineProvider/BaseHTMLEngineProvider.tsx +++ b/src/components/HTMLEngineProvider/BaseHTMLEngineProvider.tsx @@ -49,12 +49,22 @@ function BaseHTMLEngineProvider({textSelectable = false, children, enableExperim }), comment: HTMLElementModel.fromCustomModel({ tagName: 'comment', - mixedUAStyles: {whiteSpace: 'pre'}, + getMixedUAStyles: (tnode) => { + if (tnode.attributes.islarge === undefined) { + return {whiteSpace: 'pre'}; + } + return {whiteSpace: 'pre', ...styles.onlyEmojisTextLineHeight}; + }, contentModel: HTMLContentModel.block, }), 'email-comment': HTMLElementModel.fromCustomModel({ tagName: 'email-comment', - mixedUAStyles: {whiteSpace: 'normal'}, + getMixedUAStyles: (tnode) => { + if (tnode.attributes.islarge === undefined) { + return {whiteSpace: 'normal'}; + } + return {whiteSpace: 'normal', ...styles.onlyEmojisTextLineHeight}; + }, contentModel: HTMLContentModel.block, }), strong: HTMLElementModel.fromCustomModel({ diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/EditedRenderer.tsx b/src/components/HTMLEngineProvider/HTMLRenderers/EditedRenderer.tsx index 88e5c1f42555..e8d7e0e85fdf 100644 --- a/src/components/HTMLEngineProvider/HTMLRenderers/EditedRenderer.tsx +++ b/src/components/HTMLEngineProvider/HTMLRenderers/EditedRenderer.tsx @@ -12,9 +12,8 @@ function EditedRenderer({tnode, TDefaultRenderer, style, ...defaultRendererProps const styles = useThemeStyles(); const {translate} = useLocalize(); const isPendingDelete = !!(tnode.attributes.deleted !== undefined); - const isLarge = !!(tnode.attributes.islarge !== undefined); return ( - + ${html}` : `${html}`; +function RenderCommentHTML({html, source, containsOnlyEmojis}: RenderCommentHTMLProps) { + const commentHtml = + source === 'email' ? `${html}` : `${html}`; return ; } diff --git a/src/pages/home/report/comment/TextCommentFragment.tsx b/src/pages/home/report/comment/TextCommentFragment.tsx index 5dc8c6a85b85..f8ea9b56871f 100644 --- a/src/pages/home/report/comment/TextCommentFragment.tsx +++ b/src/pages/home/report/comment/TextCommentFragment.tsx @@ -68,13 +68,12 @@ function TextCommentFragment({fragment, styleAsDeleted, styleAsMuted = false, so const containsOnlyEmojis = EmojiUtils.containsOnlyEmojis(text ?? ''); const containsEmojis = CONST.REGEX.ALL_EMOJIS.test(text ?? ''); if (!shouldRenderAsText(html, text ?? '') && !(containsOnlyEmojis && styleAsDeleted)) { - const editedTag = fragment?.isEdited ? `` : ''; + const editedTag = fragment?.isEdited ? `` : ''; const htmlWithDeletedTag = styleAsDeleted ? `${html}` : html; let htmlContent = htmlWithDeletedTag; if (containsOnlyEmojis) { htmlContent = Str.replaceAll(htmlContent, '', ''); - htmlContent = Str.replaceAll(htmlContent, '
', '
'); } else if (containsEmojis) { htmlContent = Str.replaceAll(htmlWithDeletedTag, '', ''); } @@ -87,6 +86,7 @@ function TextCommentFragment({fragment, styleAsDeleted, styleAsMuted = false, so return ( From b144f93e9f7b928f9db0fca317a71d67fcc4f19b Mon Sep 17 00:00:00 2001 From: Bernhard Owen Josephus Date: Wed, 11 Dec 2024 15:23:34 +0800 Subject: [PATCH 02/70] fix missing prop --- src/pages/home/report/comment/AttachmentCommentFragment.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/src/pages/home/report/comment/AttachmentCommentFragment.tsx b/src/pages/home/report/comment/AttachmentCommentFragment.tsx index f9f86f0a9cd0..9f70f03f352b 100644 --- a/src/pages/home/report/comment/AttachmentCommentFragment.tsx +++ b/src/pages/home/report/comment/AttachmentCommentFragment.tsx @@ -18,6 +18,7 @@ function AttachmentCommentFragment({addExtraMargin, html, source, styleAsDeleted return ( From 96f26b963d6106b9074f1c71909d4399598e5707 Mon Sep 17 00:00:00 2001 From: truph01 Date: Wed, 18 Dec 2024 10:31:04 +0700 Subject: [PATCH 03/70] fix: dismiss keyboard once open modal --- src/components/Modal/BaseModal.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/components/Modal/BaseModal.tsx b/src/components/Modal/BaseModal.tsx index e1c5a7c48b86..ef1bf150b9f2 100644 --- a/src/components/Modal/BaseModal.tsx +++ b/src/components/Modal/BaseModal.tsx @@ -1,6 +1,6 @@ import {PortalHost} from '@gorhom/portal'; import React, {forwardRef, useCallback, useEffect, useMemo, useRef} from 'react'; -import {View} from 'react-native'; +import {Keyboard, View} from 'react-native'; import ReactNativeModal from 'react-native-modal'; import ColorSchemeWrapper from '@components/ColorSchemeWrapper'; import FocusTrapForModal from '@components/FocusTrap/FocusTrapForModal'; @@ -105,6 +105,7 @@ function BaseModal( let removeOnCloseListener: () => void; if (isVisible) { Modal.willAlertModalBecomeVisible(true, type === CONST.MODAL.MODAL_TYPE.POPOVER || type === CONST.MODAL.MODAL_TYPE.BOTTOM_DOCKED); + Keyboard.dismiss(); // To handle closing any modal already visible when this modal is mounted, i.e. PopoverReportActionContextMenu removeOnCloseListener = Modal.setCloseModal(onClose); } From e67cdb6986032ba9a9b2c9c8c678918f520b7203 Mon Sep 17 00:00:00 2001 From: 289Adam289 Date: Thu, 19 Dec 2024 15:40:54 +0100 Subject: [PATCH 04/70] add different types of quotes to parser --- src/libs/SearchParser/autocompleteParser.js | 40 ++++++++++----------- src/libs/SearchParser/baseRules.peggy | 2 +- src/libs/SearchParser/searchParser.js | 40 ++++++++++----------- src/libs/SearchQueryUtils.ts | 3 +- 4 files changed, 42 insertions(+), 43 deletions(-) diff --git a/src/libs/SearchParser/autocompleteParser.js b/src/libs/SearchParser/autocompleteParser.js index 0b456b5823b1..d8705428bc1f 100644 --- a/src/libs/SearchParser/autocompleteParser.js +++ b/src/libs/SearchParser/autocompleteParser.js @@ -212,13 +212,13 @@ function peg$parse(input, options) { var peg$c34 = ">"; var peg$c35 = "<="; var peg$c36 = "<"; - var peg$c37 = "\""; var peg$r0 = /^[:=]/; - var peg$r1 = /^[^ ,"\t\n\r]/; - var peg$r2 = /^[^"\r\n]/; - var peg$r3 = /^[^ ,\t\n\r]/; - var peg$r4 = /^[ \t\r\n]/; + var peg$r1 = /^[^ ,"\u201D\u201C\t\n\r]/; + var peg$r2 = /^["\u201C-\u201D]/; + var peg$r3 = /^[^"\u201D\u201C\r\n]/; + var peg$r4 = /^[^ ,\t\n\r]/; + var peg$r5 = /^[ \t\r\n]/; var peg$e0 = peg$literalExpectation(",", false); var peg$e1 = peg$otherExpectation("key"); @@ -261,9 +261,9 @@ function peg$parse(input, options) { var peg$e38 = peg$literalExpectation("<=", false); var peg$e39 = peg$literalExpectation("<", false); var peg$e40 = peg$otherExpectation("quote"); - var peg$e41 = peg$classExpectation([" ", ",", "\"", "\t", "\n", "\r"], true, false); - var peg$e42 = peg$literalExpectation("\"", false); - var peg$e43 = peg$classExpectation(["\"", "\r", "\n"], true, false); + var peg$e41 = peg$classExpectation([" ", ",", "\"", "\u201D", "\u201C", "\t", "\n", "\r"], true, false); + var peg$e42 = peg$classExpectation(["\"", ["\u201C", "\u201D"]], false, false); + var peg$e43 = peg$classExpectation(["\"", "\u201D", "\u201C", "\r", "\n"], true, false); var peg$e44 = peg$classExpectation([" ", ",", "\t", "\n", "\r"], true, false); var peg$e45 = peg$otherExpectation("word"); var peg$e46 = peg$otherExpectation("whitespace"); @@ -1418,8 +1418,8 @@ function peg$parse(input, options) { if (peg$silentFails === 0) { peg$fail(peg$e41); } } } - if (input.charCodeAt(peg$currPos) === 34) { - s2 = peg$c37; + s2 = input.charAt(peg$currPos); + if (peg$r2.test(s2)) { peg$currPos++; } else { s2 = peg$FAILED; @@ -1428,7 +1428,7 @@ function peg$parse(input, options) { if (s2 !== peg$FAILED) { s3 = []; s4 = input.charAt(peg$currPos); - if (peg$r2.test(s4)) { + if (peg$r3.test(s4)) { peg$currPos++; } else { s4 = peg$FAILED; @@ -1437,15 +1437,15 @@ function peg$parse(input, options) { while (s4 !== peg$FAILED) { s3.push(s4); s4 = input.charAt(peg$currPos); - if (peg$r2.test(s4)) { + if (peg$r3.test(s4)) { peg$currPos++; } else { s4 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e43); } } } - if (input.charCodeAt(peg$currPos) === 34) { - s4 = peg$c37; + s4 = input.charAt(peg$currPos); + if (peg$r2.test(s4)) { peg$currPos++; } else { s4 = peg$FAILED; @@ -1454,7 +1454,7 @@ function peg$parse(input, options) { if (s4 !== peg$FAILED) { s5 = []; s6 = input.charAt(peg$currPos); - if (peg$r3.test(s6)) { + if (peg$r4.test(s6)) { peg$currPos++; } else { s6 = peg$FAILED; @@ -1463,7 +1463,7 @@ function peg$parse(input, options) { while (s6 !== peg$FAILED) { s5.push(s6); s6 = input.charAt(peg$currPos); - if (peg$r3.test(s6)) { + if (peg$r4.test(s6)) { peg$currPos++; } else { s6 = peg$FAILED; @@ -1496,7 +1496,7 @@ function peg$parse(input, options) { s0 = peg$currPos; s1 = []; s2 = input.charAt(peg$currPos); - if (peg$r3.test(s2)) { + if (peg$r4.test(s2)) { peg$currPos++; } else { s2 = peg$FAILED; @@ -1506,7 +1506,7 @@ function peg$parse(input, options) { while (s2 !== peg$FAILED) { s1.push(s2); s2 = input.charAt(peg$currPos); - if (peg$r3.test(s2)) { + if (peg$r4.test(s2)) { peg$currPos++; } else { s2 = peg$FAILED; @@ -1548,7 +1548,7 @@ function peg$parse(input, options) { peg$silentFails++; s0 = []; s1 = input.charAt(peg$currPos); - if (peg$r4.test(s1)) { + if (peg$r5.test(s1)) { peg$currPos++; } else { s1 = peg$FAILED; @@ -1557,7 +1557,7 @@ function peg$parse(input, options) { while (s1 !== peg$FAILED) { s0.push(s1); s1 = input.charAt(peg$currPos); - if (peg$r4.test(s1)) { + if (peg$r5.test(s1)) { peg$currPos++; } else { s1 = peg$FAILED; diff --git a/src/libs/SearchParser/baseRules.peggy b/src/libs/SearchParser/baseRules.peggy index cc1305adc8b3..ebde336a6ead 100644 --- a/src/libs/SearchParser/baseRules.peggy +++ b/src/libs/SearchParser/baseRules.peggy @@ -56,7 +56,7 @@ operator "operator" / "<" { return "lt"; } quotedString "quote" - = start:[^ ,"\t\n\r]* "\"" inner:[^"\r\n]* "\"" end:[^ ,\t\n\r]* { + = start:[^ ,"”“\t\n\r]* ("“" / "\"" / "”") inner:[^"”“\r\n]* ("“" / "\"" / "”") end:[^ ,\t\n\r]* { return [...start, '"', ...inner, '"', ...end].join(""); } diff --git a/src/libs/SearchParser/searchParser.js b/src/libs/SearchParser/searchParser.js index 47b534d32cad..7d06dc980e95 100644 --- a/src/libs/SearchParser/searchParser.js +++ b/src/libs/SearchParser/searchParser.js @@ -217,14 +217,14 @@ function peg$parse(input, options) { var peg$c34 = ">"; var peg$c35 = "<="; var peg$c36 = "<"; - var peg$c37 = "\""; var peg$r0 = /^[^ \t\r\n]/; var peg$r1 = /^[:=]/; - var peg$r2 = /^[^ ,"\t\n\r]/; - var peg$r3 = /^[^"\r\n]/; - var peg$r4 = /^[^ ,\t\n\r]/; - var peg$r5 = /^[ \t\r\n]/; + var peg$r2 = /^[^ ,"\u201D\u201C\t\n\r]/; + var peg$r3 = /^["\u201C-\u201D]/; + var peg$r4 = /^[^"\u201D\u201C\r\n]/; + var peg$r5 = /^[^ ,\t\n\r]/; + var peg$r6 = /^[ \t\r\n]/; var peg$e0 = peg$classExpectation([" ", "\t", "\r", "\n"], true, false); var peg$e1 = peg$otherExpectation("key"); @@ -269,9 +269,9 @@ function peg$parse(input, options) { var peg$e40 = peg$literalExpectation("<=", false); var peg$e41 = peg$literalExpectation("<", false); var peg$e42 = peg$otherExpectation("quote"); - var peg$e43 = peg$classExpectation([" ", ",", "\"", "\t", "\n", "\r"], true, false); - var peg$e44 = peg$literalExpectation("\"", false); - var peg$e45 = peg$classExpectation(["\"", "\r", "\n"], true, false); + var peg$e43 = peg$classExpectation([" ", ",", "\"", "\u201D", "\u201C", "\t", "\n", "\r"], true, false); + var peg$e44 = peg$classExpectation(["\"", ["\u201C", "\u201D"]], false, false); + var peg$e45 = peg$classExpectation(["\"", "\u201D", "\u201C", "\r", "\n"], true, false); var peg$e46 = peg$classExpectation([" ", ",", "\t", "\n", "\r"], true, false); var peg$e47 = peg$otherExpectation("word"); var peg$e48 = peg$otherExpectation("whitespace"); @@ -1616,8 +1616,8 @@ function peg$parse(input, options) { if (peg$silentFails === 0) { peg$fail(peg$e43); } } } - if (input.charCodeAt(peg$currPos) === 34) { - s2 = peg$c37; + s2 = input.charAt(peg$currPos); + if (peg$r3.test(s2)) { peg$currPos++; } else { s2 = peg$FAILED; @@ -1626,7 +1626,7 @@ function peg$parse(input, options) { if (s2 !== peg$FAILED) { s3 = []; s4 = input.charAt(peg$currPos); - if (peg$r3.test(s4)) { + if (peg$r4.test(s4)) { peg$currPos++; } else { s4 = peg$FAILED; @@ -1635,15 +1635,15 @@ function peg$parse(input, options) { while (s4 !== peg$FAILED) { s3.push(s4); s4 = input.charAt(peg$currPos); - if (peg$r3.test(s4)) { + if (peg$r4.test(s4)) { peg$currPos++; } else { s4 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e45); } } } - if (input.charCodeAt(peg$currPos) === 34) { - s4 = peg$c37; + s4 = input.charAt(peg$currPos); + if (peg$r3.test(s4)) { peg$currPos++; } else { s4 = peg$FAILED; @@ -1652,7 +1652,7 @@ function peg$parse(input, options) { if (s4 !== peg$FAILED) { s5 = []; s6 = input.charAt(peg$currPos); - if (peg$r4.test(s6)) { + if (peg$r5.test(s6)) { peg$currPos++; } else { s6 = peg$FAILED; @@ -1661,7 +1661,7 @@ function peg$parse(input, options) { while (s6 !== peg$FAILED) { s5.push(s6); s6 = input.charAt(peg$currPos); - if (peg$r4.test(s6)) { + if (peg$r5.test(s6)) { peg$currPos++; } else { s6 = peg$FAILED; @@ -1694,7 +1694,7 @@ function peg$parse(input, options) { s0 = peg$currPos; s1 = []; s2 = input.charAt(peg$currPos); - if (peg$r4.test(s2)) { + if (peg$r5.test(s2)) { peg$currPos++; } else { s2 = peg$FAILED; @@ -1704,7 +1704,7 @@ function peg$parse(input, options) { while (s2 !== peg$FAILED) { s1.push(s2); s2 = input.charAt(peg$currPos); - if (peg$r4.test(s2)) { + if (peg$r5.test(s2)) { peg$currPos++; } else { s2 = peg$FAILED; @@ -1746,7 +1746,7 @@ function peg$parse(input, options) { peg$silentFails++; s0 = []; s1 = input.charAt(peg$currPos); - if (peg$r5.test(s1)) { + if (peg$r6.test(s1)) { peg$currPos++; } else { s1 = peg$FAILED; @@ -1755,7 +1755,7 @@ function peg$parse(input, options) { while (s1 !== peg$FAILED) { s0.push(s1); s1 = input.charAt(peg$currPos); - if (peg$r5.test(s1)) { + if (peg$r6.test(s1)) { peg$currPos++; } else { s1 = peg$FAILED; diff --git a/src/libs/SearchQueryUtils.ts b/src/libs/SearchQueryUtils.ts index 73c83cb33b83..6e3ad5873141 100644 --- a/src/libs/SearchQueryUtils.ts +++ b/src/libs/SearchQueryUtils.ts @@ -70,8 +70,7 @@ const UserFriendlyKeyMap: Record Date: Fri, 20 Dec 2024 13:38:26 -0700 Subject: [PATCH 05/70] Update Approve-expenses.md Adding images and url links into the Approve an expense help article. GH- https://github.com/Expensify/Expensify/issues/438175 --- .../expenses-&-payments/Approve-expenses.md | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/docs/articles/new-expensify/expenses-&-payments/Approve-expenses.md b/docs/articles/new-expensify/expenses-&-payments/Approve-expenses.md index 77587cc124f0..01799f30fad6 100644 --- a/docs/articles/new-expensify/expenses-&-payments/Approve-expenses.md +++ b/docs/articles/new-expensify/expenses-&-payments/Approve-expenses.md @@ -6,13 +6,13 @@ description: Approve, hold, and unapprove submitted expenses Expenses can be created through manual entry, tracking distance, or scanning a receipt. They can be submitted to an individual or a workspace. -This help article has more details about creating and submitting an expense to an individual or a workspace. +This [help article](https://help.expensify.com/articles/new-expensify/expenses-&-payments/Create-an-expense) has more details about creating and submitting an expense to an individual or a workspace. # Receiving an expense from an Individual When an expense is submitted to an individual, it doesn’t need approval. It only needs to be paid. -This help article has the steps to pay the expense. +This [help article](https://help.expensify.com/articles/new-expensify/expenses-&-payments/Pay-an-expense) has the steps to pay the expense. # Receiving a workspace expense @@ -33,7 +33,7 @@ As a workspace admin, you can set an [approval workflow](https://help.expensify. 2. Click the expense in the email to be directed to New Expensify, where you can review it. 3. Click on the expense to view the receipt, amount, description, and additional details the submitter provides. 4. Click **Approve**. -5. When you are ready to pay the expense, follow the steps in this help article. +5. When you are ready to pay the expense, follow the steps in this [help article](https://help.expensify.com/articles/new-expensify/expenses-&-payments/Pay-an-expense). {% include end-option.html %} {% include option.html value="mobile" %} @@ -91,6 +91,12 @@ When you’re ready to remove the hold, {% include end-selector.html %} +![Use Search to find an expense]({{site.url}}/assets/images/search-hold-01.png){:width="100%"} +![Click on top of expense]({{site.url}}/assets/images/search-hold-02.png){:width="100%"} +![Click Hold]({{site.url}}/assets/images/search-hold-03.png){:width="100%"} +![Click Unhold]({{site.url}}/assets/images/search-hold-04.png){:width="100%"} +![Click Approve]({{site.url}}/assets/images/search-hold-05.png){:width="100%"} + {% include info.html %} Held expenses will not be available for payment until they have been approved. {% include end-info.html %} From 94fa563323e20188b38516a6a7fb7b6a2e6b2b48 Mon Sep 17 00:00:00 2001 From: Bernhard Owen Josephus Date: Fri, 27 Dec 2024 22:47:09 +0700 Subject: [PATCH 06/70] apply emoji text only to whole html --- src/components/HTMLEngineProvider/BaseHTMLEngineProvider.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/HTMLEngineProvider/BaseHTMLEngineProvider.tsx b/src/components/HTMLEngineProvider/BaseHTMLEngineProvider.tsx index bf69fe4dfbdb..2e30eade21b6 100755 --- a/src/components/HTMLEngineProvider/BaseHTMLEngineProvider.tsx +++ b/src/components/HTMLEngineProvider/BaseHTMLEngineProvider.tsx @@ -53,7 +53,7 @@ function BaseHTMLEngineProvider({textSelectable = false, children, enableExperim if (tnode.attributes.islarge === undefined) { return {whiteSpace: 'pre'}; } - return {whiteSpace: 'pre', ...styles.onlyEmojisTextLineHeight}; + return {whiteSpace: 'pre', ...styles.onlyEmojisText}; }, contentModel: HTMLContentModel.block, }), @@ -63,7 +63,7 @@ function BaseHTMLEngineProvider({textSelectable = false, children, enableExperim if (tnode.attributes.islarge === undefined) { return {whiteSpace: 'normal'}; } - return {whiteSpace: 'normal', ...styles.onlyEmojisTextLineHeight}; + return {whiteSpace: 'normal', ...styles.onlyEmojisText}; }, contentModel: HTMLContentModel.block, }), From 7bf5fef6a8aaff4e813f15c2602b320e50adb88a Mon Sep 17 00:00:00 2001 From: Andrii Vitiv Date: Mon, 16 Dec 2024 21:43:57 +0200 Subject: [PATCH 07/70] Fix error on Android when submitting a tracked expense --- .../CategorizeTrackedExpenseParams.ts | 3 --- .../ConvertTrackedExpenseToRequestParams.ts | 3 --- .../parameters/ShareTrackedExpenseParams.ts | 3 --- src/libs/actions/IOU.ts | 22 ++++++------------- 4 files changed, 7 insertions(+), 24 deletions(-) diff --git a/src/libs/API/parameters/CategorizeTrackedExpenseParams.ts b/src/libs/API/parameters/CategorizeTrackedExpenseParams.ts index 78eb0adecc5e..d6d8f4169d76 100644 --- a/src/libs/API/parameters/CategorizeTrackedExpenseParams.ts +++ b/src/libs/API/parameters/CategorizeTrackedExpenseParams.ts @@ -1,5 +1,3 @@ -import type {Receipt} from '@src/types/onyx/Transaction'; - type CategorizeTrackedExpenseParams = { amount: number; currency: string; @@ -16,7 +14,6 @@ type CategorizeTrackedExpenseParams = { reportPreviewReportActionID: string; category?: string; tag?: string; - receipt?: Receipt; taxCode: string; taxAmount: number; billable?: boolean; diff --git a/src/libs/API/parameters/ConvertTrackedExpenseToRequestParams.ts b/src/libs/API/parameters/ConvertTrackedExpenseToRequestParams.ts index c51161b043a8..2942923f6b37 100644 --- a/src/libs/API/parameters/ConvertTrackedExpenseToRequestParams.ts +++ b/src/libs/API/parameters/ConvertTrackedExpenseToRequestParams.ts @@ -1,5 +1,3 @@ -import type {Receipt} from '@src/types/onyx/Transaction'; - type ConvertTrackedExpenseToRequestParams = { amount: number; currency: string; @@ -11,7 +9,6 @@ type ConvertTrackedExpenseToRequestParams = { transactionID: string; actionableWhisperReportActionID: string; createdChatReportActionID: string; - receipt?: Receipt; moneyRequestReportID: string; moneyRequestCreatedReportActionID: string; moneyRequestPreviewReportActionID: string; diff --git a/src/libs/API/parameters/ShareTrackedExpenseParams.ts b/src/libs/API/parameters/ShareTrackedExpenseParams.ts index cee4bc40d9ac..dc95d211427b 100644 --- a/src/libs/API/parameters/ShareTrackedExpenseParams.ts +++ b/src/libs/API/parameters/ShareTrackedExpenseParams.ts @@ -1,5 +1,3 @@ -import type {Receipt} from '@src/types/onyx/Transaction'; - type ShareTrackedExpenseParams = { amount: number; currency: string; @@ -16,7 +14,6 @@ type ShareTrackedExpenseParams = { reportPreviewReportActionID: string; category?: string; tag?: string; - receipt?: Receipt; taxCode: string; taxAmount: number; billable?: boolean; diff --git a/src/libs/actions/IOU.ts b/src/libs/actions/IOU.ts index 5e868dc2a65a..ebc4c1c009eb 100644 --- a/src/libs/actions/IOU.ts +++ b/src/libs/actions/IOU.ts @@ -125,7 +125,6 @@ type CategorizeTrackedExpenseTransactionParams = { category?: string; tag?: string; billable?: boolean; - receipt?: Receipt; }; type CategorizeTrackedExpensePolicyParams = { policyID: string; @@ -3613,7 +3612,6 @@ function convertTrackedExpenseToRequest( merchant: string, created: string, attendees?: Attendee[], - receipt?: Receipt, ) { const {optimisticData, successData, failureData} = onyxData; @@ -3643,7 +3641,6 @@ function convertTrackedExpenseToRequest( comment, created, merchant, - receipt, payerAccountID, payerEmail, chatReportID, @@ -3685,10 +3682,10 @@ function categorizeTrackedExpense(trackedExpenseParams: CategorizeTrackedExpense successData?.push(...moveTransactionSuccessData); failureData?.push(...moveTransactionFailureData); const parameters = { - onyxData, ...reportInformation, ...policyParams, ...transactionParams, + linkedTrackedExpenseReportAction: undefined, modifiedExpenseReportActionID, policyExpenseChatReportID: createdWorkspaceParams?.expenseChatReportID, policyExpenseCreatedReportActionID: createdWorkspaceParams?.expenseCreatedReportActionID, @@ -3727,7 +3724,6 @@ function shareTrackedExpense( taxCode = '', taxAmount = 0, billable?: boolean, - receipt?: Receipt, createdWorkspaceParams?: CreateWorkspaceParams, ) { const {optimisticData, successData, failureData} = onyxData ?? {}; @@ -3770,7 +3766,6 @@ function shareTrackedExpense( taxCode, taxAmount, billable, - receipt, policyExpenseChatReportID: createdWorkspaceParams?.expenseChatReportID, policyExpenseCreatedReportActionID: createdWorkspaceParams?.expenseCreatedReportActionID, adminsChatReportID: createdWorkspaceParams?.adminsChatReportID, @@ -3863,7 +3858,6 @@ function requestMoney(requestMoneyInformation: RequestMoneyInformation) { merchant, created, attendees, - receipt, ); break; } @@ -3883,7 +3877,7 @@ function requestMoney(requestMoneyInformation: RequestMoneyInformation) { createdChatReportActionID, createdIOUReportActionID, reportPreviewReportActionID: reportPreviewAction.reportActionID, - receipt, + receipt: receipt instanceof Blob ? receipt : undefined, receiptState: receipt?.state, category, tag, @@ -4062,7 +4056,7 @@ function trackExpense( if (!linkedTrackedExpenseReportAction || !actionableWhisperReportActionID || !linkedTrackedExpenseReportID) { return; } - const transactionParams = { + const transactionParams: CategorizeTrackedExpenseTransactionParams = { transactionID: transaction?.transactionID ?? '-1', amount, currency, @@ -4074,13 +4068,12 @@ function trackExpense( category, tag, billable, - receipt: trackedReceipt, }; - const policyParams = { + const policyParams: CategorizeTrackedExpensePolicyParams = { policyID: chatReport?.policyID ?? '-1', isDraftPolicy, }; - const reportInformation = { + const reportInformation: CategorizeTrackedExpenseReportInformation = { moneyRequestPreviewReportActionID: iouAction?.reportActionID ?? '-1', moneyRequestReportID: iouReport?.reportID ?? '-1', moneyRequestCreatedReportActionID: createdIOUReportActionID ?? '-1', @@ -4090,7 +4083,7 @@ function trackExpense( transactionThreadReportID: transactionThreadReportID ?? '-1', reportPreviewReportActionID: reportPreviewAction?.reportActionID ?? '-1', }; - const trackedExpenseParams = { + const trackedExpenseParams: CategorizeTrackedExpenseParams = { onyxData, reportInformation, transactionParams, @@ -4127,7 +4120,6 @@ function trackExpense( taxCode, taxAmount, billable, - trackedReceipt, createdWorkspaceParams, ); break; @@ -4146,7 +4138,7 @@ function trackExpense( createdChatReportActionID: createdChatReportActionID ?? '-1', createdIOUReportActionID, reportPreviewReportActionID: reportPreviewAction?.reportActionID, - receipt: trackedReceipt, + receipt: trackedReceipt instanceof Blob ? trackedReceipt : undefined, receiptState: trackedReceipt?.state, category, tag, From d1ab384c8db63f2d87ac86dcc26e8ae2b0136d21 Mon Sep 17 00:00:00 2001 From: Andrii Vitiv Date: Thu, 26 Dec 2024 01:58:47 +0200 Subject: [PATCH 08/70] Log unsupported FormData values --- src/libs/HttpUtils.ts | 25 +++++++++++++++++++++++-- 1 file changed, 23 insertions(+), 2 deletions(-) diff --git a/src/libs/HttpUtils.ts b/src/libs/HttpUtils.ts index 66ce71451c17..173f3c198742 100644 --- a/src/libs/HttpUtils.ts +++ b/src/libs/HttpUtils.ts @@ -160,10 +160,12 @@ function processHTTPRequest(url: string, method: RequestType = 'get', body: Form function xhr(command: string, data: Record, type: RequestType = CONST.NETWORK.METHOD.POST, shouldUseSecure = false): Promise { const formData = new FormData(); Object.keys(data).forEach((key) => { - if (typeof data[key] === 'undefined') { + const value = data[key]; + if (value === undefined) { return; } - formData.append(key, data[key] as string | Blob); + validateFormDataParameter(command, key, value); + formData.append(key, value as string | Blob); }); const url = ApiUtils.getCommandURL({shouldUseSecure, command}); @@ -172,6 +174,25 @@ function xhr(command: string, data: Record, type: RequestType = return processHTTPRequest(url, type, formData, abortSignalController?.signal); } +/** + * Ensures no value of type `object` other than Blob or its subclasses is passed to XMLHttpRequest. + * Otherwise, it will be incorrectly serialized as `[object Object]` and cause an error on Android. + * See https://github.com/Expensify/App/issues/45086 + */ +function validateFormDataParameter(command: string, key: string, value: unknown) { + // eslint-disable-next-line @typescript-eslint/no-shadow + const isValid = (value: unknown) => value === null || typeof value !== 'object' || value instanceof Blob; + if (Array.isArray(value)) { + if (value.every(isValid)) { + return; + } + } else if (isValid(value)) { + return; + } + // eslint-disable-next-line no-console + console.warn(`An unsupported value was passed to command '${command}' (parameter: '${key}'). Only Blob and primitive types are allowed.`); +} + function cancelPendingRequests(command: AbortCommand = ABORT_COMMANDS.All) { const controller = abortControllerMap.get(command); From ea111b9071081926d33f658124467d4de6172ecb Mon Sep 17 00:00:00 2001 From: Andrii Vitiv Date: Sat, 28 Dec 2024 00:46:50 +0200 Subject: [PATCH 09/70] Add tests --- tests/actions/IOUTest.ts | 114 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 114 insertions(+) diff --git a/tests/actions/IOUTest.ts b/tests/actions/IOUTest.ts index 415612afe414..90bba94f1a3b 100644 --- a/tests/actions/IOUTest.ts +++ b/tests/actions/IOUTest.ts @@ -1,9 +1,12 @@ import isEqual from 'lodash/isEqual'; import type {OnyxCollection, OnyxEntry, OnyxInputValue} from 'react-native-onyx'; import Onyx from 'react-native-onyx'; +import {WRITE_COMMANDS} from '@libs/API/types'; +import type {ApiCommand} from '@libs/API/types'; import type {OptimisticChatReport} from '@libs/ReportUtils'; import * as TransactionUtils from '@libs/TransactionUtils'; import CONST from '@src/CONST'; +import type {IOUAction} from '@src/CONST'; import * as IOU from '@src/libs/actions/IOU'; import OnyxUpdateManager from '@src/libs/actions/OnyxUpdateManager'; import * as PolicyActions from '@src/libs/actions/Policy/Policy'; @@ -41,6 +44,7 @@ jest.mock('@src/libs/Navigation/Navigation', () => ({ dismissModal: jest.fn(), dismissModalWithReport: jest.fn(), goBack: jest.fn(), + setNavigationActionToMicrotaskQueue: jest.fn(), })); jest.mock('@src/libs/Navigation/isSearchTopmostCentralPane', () => jest.fn()); @@ -3912,4 +3916,114 @@ describe('actions/IOU', () => { }); }); }); + + describe('should have valid parameters', () => { + let writeSpy: jest.SpyInstance; + const isValid = (value: unknown) => !value || typeof value !== 'object' || value instanceof Blob; + + beforeEach(() => { + // eslint-disable-next-line rulesdir/no-multiple-api-calls + writeSpy = jest.spyOn(API, 'write').mockImplementation(jest.fn()); + }); + + afterEach(() => { + writeSpy.mockRestore(); + }); + + test.each([ + [WRITE_COMMANDS.REQUEST_MONEY, CONST.IOU.ACTION.CREATE], + [WRITE_COMMANDS.CONVERT_TRACKED_EXPENSE_TO_REQUEST, CONST.IOU.ACTION.SUBMIT], + ])('%s', async (expectedCommand: ApiCommand, action: IOUAction) => { + // When an expense is created + IOU.requestMoney({ + action, + report: {reportID: ''}, + participantParams: { + payeeEmail: RORY_EMAIL, + payeeAccountID: RORY_ACCOUNT_ID, + participant: {login: CARLOS_EMAIL, accountID: CARLOS_ACCOUNT_ID}, + }, + transactionParams: { + amount: 10000, + attendees: [], + currency: CONST.CURRENCY.USD, + created: '', + merchant: 'KFC', + comment: '', + linkedTrackedExpenseReportAction: { + reportActionID: '', + actionName: CONST.REPORT.ACTIONS.TYPE.IOU, + created: '2024-10-30', + }, + actionableWhisperReportActionID: '1', + linkedTrackedExpenseReportID: '1', + }, + }); + + await waitForBatchedUpdates(); + + // Then the correct API request should be made + expect(writeSpy).toHaveBeenCalledTimes(1); + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const [command, params] = writeSpy.mock.calls.at(0); + expect(command).toBe(expectedCommand); + + // And the parameters should be supported by XMLHttpRequest + Object.values(params as Record).forEach((value) => { + expect(Array.isArray(value) ? value.every(isValid) : isValid(value)).toBe(true); + }); + }); + + test.each([ + [WRITE_COMMANDS.TRACK_EXPENSE, CONST.IOU.ACTION.CREATE], + [WRITE_COMMANDS.CATEGORIZE_TRACKED_EXPENSE, CONST.IOU.ACTION.CATEGORIZE], + [WRITE_COMMANDS.SHARE_TRACKED_EXPENSE, CONST.IOU.ACTION.SHARE], + ])('%s', async (expectedCommand: ApiCommand, action: IOUAction) => { + // When a track expense is created + IOU.trackExpense( + {reportID: ''}, + 10000, + CONST.CURRENCY.USD, + '2024-10-30', + 'KFC', + RORY_EMAIL, + RORY_ACCOUNT_ID, + {login: CARLOS_EMAIL, accountID: CARLOS_ACCOUNT_ID}, + '', + false, + {}, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + action, + '1', + { + reportActionID: '', + actionName: CONST.REPORT.ACTIONS.TYPE.IOU, + created: '2024-10-30', + }, + '1', + ); + + await waitForBatchedUpdates(); + + // Then the correct API request should be made + expect(writeSpy).toHaveBeenCalledTimes(1); + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const [command, params] = writeSpy.mock.calls.at(0); + expect(command).toBe(expectedCommand); + + // And the parameters should be supported by XMLHttpRequest + Object.values(params as Record).forEach((value) => { + expect(Array.isArray(value) ? value.every(isValid) : isValid(value)).toBe(true); + }); + }); + }); }); From 8e8014485b80b72ee1cc483604ebf7f11e2e30d3 Mon Sep 17 00:00:00 2001 From: Bernhard Owen Josephus Date: Sat, 28 Dec 2024 11:25:03 +0700 Subject: [PATCH 10/70] lint --- src/components/HTMLEngineProvider/BaseHTMLEngineProvider.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/src/components/HTMLEngineProvider/BaseHTMLEngineProvider.tsx b/src/components/HTMLEngineProvider/BaseHTMLEngineProvider.tsx index 2e30eade21b6..b4002767524f 100755 --- a/src/components/HTMLEngineProvider/BaseHTMLEngineProvider.tsx +++ b/src/components/HTMLEngineProvider/BaseHTMLEngineProvider.tsx @@ -112,6 +112,7 @@ function BaseHTMLEngineProvider({textSelectable = false, children, enableExperim styles.textSupporting, styles.textLineThrough, styles.mutedNormalTextLabel, + styles.onlyEmojisText, styles.onlyEmojisTextLineHeight, ], ); From cef441f413e2abcdfc0f69cd757b4ee7cf6a358d Mon Sep 17 00:00:00 2001 From: Kevin Brian Bader Date: Mon, 30 Dec 2024 14:31:43 -0800 Subject: [PATCH 11/70] Persist Use Staging Server option when clearing cache --- src/libs/actions/App.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/libs/actions/App.ts b/src/libs/actions/App.ts index 931f9e226995..1bc9bde348fb 100644 --- a/src/libs/actions/App.ts +++ b/src/libs/actions/App.ts @@ -97,6 +97,14 @@ Onyx.connect({ }, }); +let preservedShouldUseStagingServer: boolean | undefined; +Onyx.connect({ + key: ONYXKEYS.USER, + callback: (value) => { + preservedShouldUseStagingServer = value?.shouldUseStagingServer; + }, +}); + const KEYS_TO_PRESERVE: OnyxKey[] = [ ONYXKEYS.ACCOUNT, ONYXKEYS.IS_CHECKING_PUBLIC_ROOM, @@ -540,6 +548,7 @@ function setPreservedUserSession(session: OnyxTypes.Session) { function clearOnyxAndResetApp(shouldNavigateToHomepage?: boolean) { // The value of isUsingImportedState will be lost once Onyx is cleared, so we need to store it const isStateImported = isUsingImportedState; + const shouldUseStagingServer = preservedShouldUseStagingServer; const sequentialQueue = PersistedRequests.getAll(); Onyx.clear(KEYS_TO_PRESERVE).then(() => { // Network key is preserved, so when using imported state, we should stop forcing offline mode so that the app can re-fetch the network @@ -556,6 +565,10 @@ function clearOnyxAndResetApp(shouldNavigateToHomepage?: boolean) { Onyx.set(ONYXKEYS.PRESERVED_USER_SESSION, null); } + if (shouldUseStagingServer) { + Onyx.set(ONYXKEYS.USER, {shouldUseStagingServer}); + } + // Requests in a sequential queue should be called even if the Onyx state is reset, so we do not lose any pending data. // However, the OpenApp request must be called before any other request in a queue to ensure data consistency. // To do that, sequential queue is cleared together with other keys, and then it's restored once the OpenApp request is resolved. From ee81197b9b220297e765937db889a99fa40bd7f2 Mon Sep 17 00:00:00 2001 From: jayeshmangwani Date: Wed, 1 Jan 2025 12:22:42 +0530 Subject: [PATCH 12/70] updated subscriptions page cards to have both plan types displayed --- src/CONST.ts | 44 +++++++ src/hooks/useSubscriptionPrice.ts | 45 +------ .../Subscription/SubscriptionPlan.tsx | 107 ----------------- .../SaveWithExpensifyButton/index.native.tsx | 0 .../SaveWithExpensifyButton/index.tsx | 0 .../SubscriptionPlan/SubscriptionPlanCard.tsx | 70 +++++++++++ .../Subscription/SubscriptionPlan/index.tsx | 110 ++++++++++++++++++ 7 files changed, 225 insertions(+), 151 deletions(-) delete mode 100644 src/pages/settings/Subscription/SubscriptionPlan.tsx rename src/pages/settings/Subscription/{ => SubscriptionPlan}/SaveWithExpensifyButton/index.native.tsx (100%) rename src/pages/settings/Subscription/{ => SubscriptionPlan}/SaveWithExpensifyButton/index.tsx (100%) create mode 100644 src/pages/settings/Subscription/SubscriptionPlan/SubscriptionPlanCard.tsx create mode 100644 src/pages/settings/Subscription/SubscriptionPlan/index.tsx diff --git a/src/CONST.ts b/src/CONST.ts index f295a375e1a6..c4c0a464b878 100755 --- a/src/CONST.ts +++ b/src/CONST.ts @@ -6456,6 +6456,50 @@ const CONST = { LHN_WORKSPACE_CHAT_TOOLTIP: 'workspaceChatLHNTooltip', GLOBAL_CREATE_TOOLTIP: 'globalCreateTooltip', }, + get SUBSCRIPTION_PRICES() { + return { + [this.PAYMENT_CARD_CURRENCY.USD]: { + [this.POLICY.TYPE.CORPORATE]: { + [this.SUBSCRIPTION.TYPE.ANNUAL]: 900, + [this.SUBSCRIPTION.TYPE.PAYPERUSE]: 1800, + }, + [this.POLICY.TYPE.TEAM]: { + [this.SUBSCRIPTION.TYPE.ANNUAL]: 500, + [this.SUBSCRIPTION.TYPE.PAYPERUSE]: 1000, + }, + }, + [this.PAYMENT_CARD_CURRENCY.AUD]: { + [this.POLICY.TYPE.CORPORATE]: { + [this.SUBSCRIPTION.TYPE.ANNUAL]: 1500, + [this.SUBSCRIPTION.TYPE.PAYPERUSE]: 3000, + }, + [this.POLICY.TYPE.TEAM]: { + [this.SUBSCRIPTION.TYPE.ANNUAL]: 700, + [this.SUBSCRIPTION.TYPE.PAYPERUSE]: 1400, + }, + }, + [this.PAYMENT_CARD_CURRENCY.GBP]: { + [this.POLICY.TYPE.CORPORATE]: { + [this.SUBSCRIPTION.TYPE.ANNUAL]: 700, + [this.SUBSCRIPTION.TYPE.PAYPERUSE]: 1400, + }, + [this.POLICY.TYPE.TEAM]: { + [this.SUBSCRIPTION.TYPE.ANNUAL]: 400, + [this.SUBSCRIPTION.TYPE.PAYPERUSE]: 800, + }, + }, + [this.PAYMENT_CARD_CURRENCY.NZD]: { + [this.POLICY.TYPE.CORPORATE]: { + [this.SUBSCRIPTION.TYPE.ANNUAL]: 1600, + [this.SUBSCRIPTION.TYPE.PAYPERUSE]: 3200, + }, + [this.POLICY.TYPE.TEAM]: { + [this.SUBSCRIPTION.TYPE.ANNUAL]: 800, + [this.SUBSCRIPTION.TYPE.PAYPERUSE]: 1600, + }, + }, + }; + }, } as const; type Country = keyof typeof CONST.ALL_COUNTRIES; diff --git a/src/hooks/useSubscriptionPrice.ts b/src/hooks/useSubscriptionPrice.ts index 0b71fe62c7c8..3185ee1aaa47 100644 --- a/src/hooks/useSubscriptionPrice.ts +++ b/src/hooks/useSubscriptionPrice.ts @@ -4,49 +4,6 @@ import ONYXKEYS from '@src/ONYXKEYS'; import usePreferredCurrency from './usePreferredCurrency'; import useSubscriptionPlan from './useSubscriptionPlan'; -const SUBSCRIPTION_PRICES = { - [CONST.PAYMENT_CARD_CURRENCY.USD]: { - [CONST.POLICY.TYPE.CORPORATE]: { - [CONST.SUBSCRIPTION.TYPE.ANNUAL]: 900, - [CONST.SUBSCRIPTION.TYPE.PAYPERUSE]: 1800, - }, - [CONST.POLICY.TYPE.TEAM]: { - [CONST.SUBSCRIPTION.TYPE.ANNUAL]: 500, - [CONST.SUBSCRIPTION.TYPE.PAYPERUSE]: 1000, - }, - }, - [CONST.PAYMENT_CARD_CURRENCY.AUD]: { - [CONST.POLICY.TYPE.CORPORATE]: { - [CONST.SUBSCRIPTION.TYPE.ANNUAL]: 1500, - [CONST.SUBSCRIPTION.TYPE.PAYPERUSE]: 3000, - }, - [CONST.POLICY.TYPE.TEAM]: { - [CONST.SUBSCRIPTION.TYPE.ANNUAL]: 700, - [CONST.SUBSCRIPTION.TYPE.PAYPERUSE]: 1400, - }, - }, - [CONST.PAYMENT_CARD_CURRENCY.GBP]: { - [CONST.POLICY.TYPE.CORPORATE]: { - [CONST.SUBSCRIPTION.TYPE.ANNUAL]: 700, - [CONST.SUBSCRIPTION.TYPE.PAYPERUSE]: 1400, - }, - [CONST.POLICY.TYPE.TEAM]: { - [CONST.SUBSCRIPTION.TYPE.ANNUAL]: 400, - [CONST.SUBSCRIPTION.TYPE.PAYPERUSE]: 800, - }, - }, - [CONST.PAYMENT_CARD_CURRENCY.NZD]: { - [CONST.POLICY.TYPE.CORPORATE]: { - [CONST.SUBSCRIPTION.TYPE.ANNUAL]: 1600, - [CONST.SUBSCRIPTION.TYPE.PAYPERUSE]: 3200, - }, - [CONST.POLICY.TYPE.TEAM]: { - [CONST.SUBSCRIPTION.TYPE.ANNUAL]: 800, - [CONST.SUBSCRIPTION.TYPE.PAYPERUSE]: 1600, - }, - }, -} as const; - function useSubscriptionPrice(): number { const preferredCurrency = usePreferredCurrency(); const subscriptionPlan = useSubscriptionPlan(); @@ -56,7 +13,7 @@ function useSubscriptionPrice(): number { return 0; } - return SUBSCRIPTION_PRICES[preferredCurrency][subscriptionPlan][privateSubscription.type]; + return CONST.SUBSCRIPTION_PRICES[preferredCurrency][subscriptionPlan][privateSubscription.type]; } export default useSubscriptionPrice; diff --git a/src/pages/settings/Subscription/SubscriptionPlan.tsx b/src/pages/settings/Subscription/SubscriptionPlan.tsx deleted file mode 100644 index 33933027dd45..000000000000 --- a/src/pages/settings/Subscription/SubscriptionPlan.tsx +++ /dev/null @@ -1,107 +0,0 @@ -import React from 'react'; -import {View} from 'react-native'; -import {useOnyx} from 'react-native-onyx'; -import Icon from '@components/Icon'; -import * as Expensicons from '@components/Icon/Expensicons'; -import * as Illustrations from '@components/Icon/Illustrations'; -import Section from '@components/Section'; -import Text from '@components/Text'; -import useLocalize from '@hooks/useLocalize'; -import usePreferredCurrency from '@hooks/usePreferredCurrency'; -import useSubscriptionPlan from '@hooks/useSubscriptionPlan'; -import useSubscriptionPrice from '@hooks/useSubscriptionPrice'; -import useTheme from '@hooks/useTheme'; -import useThemeStyles from '@hooks/useThemeStyles'; -import {convertToShortDisplayString} from '@libs/CurrencyUtils'; -import variables from '@styles/variables'; -import CONST from '@src/CONST'; -import ONYXKEYS from '@src/ONYXKEYS'; -import SaveWithExpensifyButton from './SaveWithExpensifyButton'; - -function SubscriptionPlan() { - const {translate} = useLocalize(); - const styles = useThemeStyles(); - const theme = useTheme(); - - const [privateSubscription] = useOnyx(ONYXKEYS.NVP_PRIVATE_SUBSCRIPTION); - - const subscriptionPlan = useSubscriptionPlan(); - const subscriptionPrice = useSubscriptionPrice(); - const preferredCurrency = usePreferredCurrency(); - - const isCollect = subscriptionPlan === CONST.POLICY.TYPE.TEAM; - const isAnnual = privateSubscription?.type === CONST.SUBSCRIPTION.TYPE.ANNUAL; - - const benefitsList = isCollect - ? [ - translate('subscription.yourPlan.collect.benefit1'), - translate('subscription.yourPlan.collect.benefit2'), - translate('subscription.yourPlan.collect.benefit3'), - translate('subscription.yourPlan.collect.benefit4'), - translate('subscription.yourPlan.collect.benefit5'), - translate('subscription.yourPlan.collect.benefit6'), - translate('subscription.yourPlan.collect.benefit7'), - ] - : [ - translate('subscription.yourPlan.control.benefit1'), - translate('subscription.yourPlan.control.benefit2'), - translate('subscription.yourPlan.control.benefit3'), - translate('subscription.yourPlan.control.benefit4'), - translate('subscription.yourPlan.control.benefit5'), - translate('subscription.yourPlan.control.benefit6'), - ]; - - return ( -
- - - {translate(`subscription.yourPlan.${isCollect ? 'collect' : 'control'}.title`)} - - {translate(`subscription.yourPlan.${isCollect ? 'collect' : 'control'}.${isAnnual ? 'priceAnnual' : 'pricePayPerUse'}`, { - lower: convertToShortDisplayString(subscriptionPrice, preferredCurrency), - upper: convertToShortDisplayString(subscriptionPrice * CONST.SUBSCRIPTION_PRICE_FACTOR, preferredCurrency), - })} - - {benefitsList.map((benefit) => ( - - - {benefit} - - ))} - - - - - {translate('subscription.yourPlan.saveWithExpensifyTitle')} - {translate('subscription.yourPlan.saveWithExpensifyDescription')} - - - -
- ); -} - -SubscriptionPlan.displayName = 'SubscriptionPlan'; - -export default SubscriptionPlan; diff --git a/src/pages/settings/Subscription/SaveWithExpensifyButton/index.native.tsx b/src/pages/settings/Subscription/SubscriptionPlan/SaveWithExpensifyButton/index.native.tsx similarity index 100% rename from src/pages/settings/Subscription/SaveWithExpensifyButton/index.native.tsx rename to src/pages/settings/Subscription/SubscriptionPlan/SaveWithExpensifyButton/index.native.tsx diff --git a/src/pages/settings/Subscription/SaveWithExpensifyButton/index.tsx b/src/pages/settings/Subscription/SubscriptionPlan/SaveWithExpensifyButton/index.tsx similarity index 100% rename from src/pages/settings/Subscription/SaveWithExpensifyButton/index.tsx rename to src/pages/settings/Subscription/SubscriptionPlan/SaveWithExpensifyButton/index.tsx diff --git a/src/pages/settings/Subscription/SubscriptionPlan/SubscriptionPlanCard.tsx b/src/pages/settings/Subscription/SubscriptionPlan/SubscriptionPlanCard.tsx new file mode 100644 index 000000000000..2822bfb36288 --- /dev/null +++ b/src/pages/settings/Subscription/SubscriptionPlan/SubscriptionPlanCard.tsx @@ -0,0 +1,70 @@ +import React from 'react'; +import {View} from 'react-native'; +import type {SvgProps} from 'react-native-svg'; +import Icon from '@components/Icon'; +import * as Expensicons from '@components/Icon/Expensicons'; +import {PressableWithFeedback} from '@components/Pressable'; +import SelectCircle from '@components/SelectCircle'; +import Text from '@components/Text'; +import useTheme from '@hooks/useTheme'; +import useThemeStyles from '@hooks/useThemeStyles'; +import variables from '@styles/variables'; + +type SubscriptionPlanCardProps = { + index: number; + plan: { + title: string; + src: React.FC; + benefits: string[]; + description: string; + isSelected: boolean; + }; +}; +function SubscriptionPlanCard({plan, index}: SubscriptionPlanCardProps) { + const styles = useThemeStyles(); + const theme = useTheme(); + + return ( + + + + + + + + + {plan.title} + {plan.description} + {plan.benefits.map((benefit) => ( + + + {benefit} + + ))} + + + ); +} + +SubscriptionPlanCard.displayName = 'SubscriptionPlanCard'; + +export default SubscriptionPlanCard; diff --git a/src/pages/settings/Subscription/SubscriptionPlan/index.tsx b/src/pages/settings/Subscription/SubscriptionPlan/index.tsx new file mode 100644 index 000000000000..26b8369f0908 --- /dev/null +++ b/src/pages/settings/Subscription/SubscriptionPlan/index.tsx @@ -0,0 +1,110 @@ +import React from 'react'; +import {View} from 'react-native'; +import {useOnyx} from 'react-native-onyx'; +import type {ValueOf} from 'type-fest'; +import Icon from '@components/Icon'; +import * as Illustrations from '@components/Icon/Illustrations'; +import Section from '@components/Section'; +import Text from '@components/Text'; +import useLocalize from '@hooks/useLocalize'; +import usePreferredCurrency from '@hooks/usePreferredCurrency'; +import useSubscriptionPlan from '@hooks/useSubscriptionPlan'; +import useThemeStyles from '@hooks/useThemeStyles'; +import {convertToShortDisplayString} from '@libs/CurrencyUtils'; +import variables from '@styles/variables'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import SaveWithExpensifyButton from './SaveWithExpensifyButton'; +import SubscriptionPlanCard from './SubscriptionPlanCard'; + +function SubscriptionPlan() { + const {translate} = useLocalize(); + const styles = useThemeStyles(); + + const [privateSubscription] = useOnyx(ONYXKEYS.NVP_PRIVATE_SUBSCRIPTION); + const subscriptionPlan = useSubscriptionPlan(); + + const preferredCurrency = usePreferredCurrency(); + + const isAnnual = privateSubscription?.type === CONST.SUBSCRIPTION.TYPE.ANNUAL; + + function getSubscriptionPrice(plan: Exclude, 'personal'>): number { + if (!subscriptionPlan || !privateSubscription?.type) { + return 0; + } + + return CONST.SUBSCRIPTION_PRICES[preferredCurrency][plan][privateSubscription.type]; + } + + const plans = [ + { + title: translate('subscription.yourPlan.collect.title'), + benefits: [ + translate('subscription.yourPlan.collect.benefit1'), + translate('subscription.yourPlan.collect.benefit2'), + translate('subscription.yourPlan.collect.benefit3'), + translate('subscription.yourPlan.collect.benefit4'), + translate('subscription.yourPlan.collect.benefit5'), + translate('subscription.yourPlan.collect.benefit6'), + translate('subscription.yourPlan.collect.benefit7'), + ], + src: Illustrations.Mailbox, + description: translate(`subscription.yourPlan.collect.${isAnnual ? 'priceAnnual' : 'pricePayPerUse'}`, { + lower: convertToShortDisplayString(getSubscriptionPrice(CONST.POLICY.TYPE.TEAM), preferredCurrency), + upper: convertToShortDisplayString(getSubscriptionPrice(CONST.POLICY.TYPE.TEAM) * CONST.SUBSCRIPTION_PRICE_FACTOR, preferredCurrency), + }), + isSelected: subscriptionPlan === CONST.POLICY.TYPE.TEAM, + }, + { + title: translate('subscription.yourPlan.control.title'), + benefits: [ + translate('subscription.yourPlan.control.benefit1'), + translate('subscription.yourPlan.control.benefit2'), + translate('subscription.yourPlan.control.benefit3'), + translate('subscription.yourPlan.control.benefit4'), + translate('subscription.yourPlan.control.benefit5'), + translate('subscription.yourPlan.control.benefit6'), + ], + src: Illustrations.ShieldYellow, + description: translate(`subscription.yourPlan.control.${isAnnual ? 'priceAnnual' : 'pricePayPerUse'}`, { + lower: convertToShortDisplayString(getSubscriptionPrice(CONST.POLICY.TYPE.CORPORATE), preferredCurrency), + upper: convertToShortDisplayString(getSubscriptionPrice(CONST.POLICY.TYPE.CORPORATE) * CONST.SUBSCRIPTION_PRICE_FACTOR, preferredCurrency), + }), + isSelected: subscriptionPlan === CONST.POLICY.TYPE.CORPORATE, + }, + ]; + + return ( +
+ + {plans.map((plan, index) => ( + + ))} + + + + + {translate('subscription.yourPlan.saveWithExpensifyTitle')} + {translate('subscription.yourPlan.saveWithExpensifyDescription')} + + + +
+ ); +} + +SubscriptionPlan.displayName = 'SubscriptionPlan'; + +export default SubscriptionPlan; From aeb7ad5c7dc2c615baa9f493c2e947445d6f16f2 Mon Sep 17 00:00:00 2001 From: jayeshmangwani Date: Wed, 1 Jan 2025 18:50:46 +0530 Subject: [PATCH 13/70] added new translations for Go to workspaces --- src/languages/en.ts | 1 + src/languages/es.ts | 1 + 2 files changed, 2 insertions(+) diff --git a/src/languages/en.ts b/src/languages/en.ts index 7dfd0511b1a1..3ba2e2dd3207 100755 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -2559,6 +2559,7 @@ const translations = { notAuthorized: `You don't have access to this page. If you're trying to join this workspace, just ask the workspace owner to add you as a member. Something else? Reach out to ${CONST.EMAIL.CONCIERGE}.`, goToRoom: ({roomName}: GoToRoomParams) => `Go to ${roomName} room`, goToWorkspace: 'Go to workspace', + goToWorkspaces: 'Go to workspaces', clearFilter: 'Clear filter', workspaceName: 'Workspace name', workspaceOwner: 'Owner', diff --git a/src/languages/es.ts b/src/languages/es.ts index 2638a3450674..88a7c19e2585 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -2582,6 +2582,7 @@ const translations = { notAuthorized: `No tienes acceso a esta página. Si estás intentando unirte a este espacio de trabajo, pide al dueño del espacio de trabajo que te añada como miembro. ¿Necesitas algo más? Comunícate con ${CONST.EMAIL.CONCIERGE}`, goToRoom: ({roomName}: GoToRoomParams) => `Ir a la sala ${roomName}`, goToWorkspace: 'Ir al espacio de trabajo', + goToWorkspaces: 'Ir a espacios de trabajo', clearFilter: 'Borrar filtro', workspaceName: 'Nombre del espacio de trabajo', workspaceOwner: 'Dueño', From b78d020f0c39ba63932ae18e87b8fb510ed3d810 Mon Sep 17 00:00:00 2001 From: jayeshmangwani Date: Wed, 1 Jan 2025 18:53:02 +0530 Subject: [PATCH 14/70] Made the policyID optional and adjusted the route URL accordingly for the downgrade and upgrade pages --- src/ROUTES.ts | 10 +++---- .../FULL_SCREEN_TO_RHP_MAPPING.ts | 1 - src/libs/Navigation/types.ts | 4 +-- .../workspace/downgrade/DowngradeIntro.tsx | 30 +++++++++++++------ .../downgrade/WorkspaceDowngradePage.tsx | 9 +++--- .../workspace/upgrade/GenericFeaturesView.tsx | 28 +++++++++++------ src/pages/workspace/upgrade/UpgradeIntro.tsx | 6 ++-- .../upgrade/WorkspaceUpgradePage.tsx | 13 ++++---- 8 files changed, 63 insertions(+), 38 deletions(-) diff --git a/src/ROUTES.ts b/src/ROUTES.ts index 57b4f65a5bc6..318c9b57e36d 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -984,13 +984,13 @@ const ROUTES = { getRoute: (policyID: string, categoryName: string) => `settings/workspaces/${policyID}/category/${encodeURIComponent(categoryName)}` as const, }, WORKSPACE_UPGRADE: { - route: 'settings/workspaces/:policyID/upgrade/:featureName?', - getRoute: (policyID: string, featureName?: string, backTo?: string) => - getUrlWithBackToParam(`settings/workspaces/${policyID}/upgrade/${encodeURIComponent(featureName ?? '')}` as const, backTo), + route: 'settings/workspaces/:policyID?/upgrade/:featureName?', + getRoute: (policyID?: string, featureName?: string, backTo?: string) => + policyID ? getUrlWithBackToParam(`settings/workspaces/${policyID}/upgrade/${encodeURIComponent(featureName ?? '')}` as const, backTo) : (`settings/workspaces/upgrade` as const), }, WORKSPACE_DOWNGRADE: { - route: 'settings/workspaces/:policyID/downgrade/', - getRoute: (policyID: string) => `settings/workspaces/${policyID}/downgrade/` as const, + route: 'settings/workspaces/:policyID?/downgrade/', + getRoute: (policyID?: string) => (policyID ? (`settings/workspaces/${policyID}/downgrade/` as const) : `settings/workspaces/downgrade`), }, WORKSPACE_CATEGORIES_SETTINGS: { route: 'settings/workspaces/:policyID/categories/settings', diff --git a/src/libs/Navigation/linkingConfig/FULL_SCREEN_TO_RHP_MAPPING.ts b/src/libs/Navigation/linkingConfig/FULL_SCREEN_TO_RHP_MAPPING.ts index 9a03409fb3a2..2c3b060e0835 100755 --- a/src/libs/Navigation/linkingConfig/FULL_SCREEN_TO_RHP_MAPPING.ts +++ b/src/libs/Navigation/linkingConfig/FULL_SCREEN_TO_RHP_MAPPING.ts @@ -9,7 +9,6 @@ const FULL_SCREEN_TO_RHP_MAPPING: Partial> = { SCREENS.WORKSPACE.CURRENCY, SCREENS.WORKSPACE.DESCRIPTION, SCREENS.WORKSPACE.SHARE, - SCREENS.WORKSPACE.DOWNGRADE, ], [SCREENS.WORKSPACE.MEMBERS]: [ SCREENS.WORKSPACE.MEMBER_DETAILS, diff --git a/src/libs/Navigation/types.ts b/src/libs/Navigation/types.ts index 7c6c568a5359..d6e19905465b 100644 --- a/src/libs/Navigation/types.ts +++ b/src/libs/Navigation/types.ts @@ -248,13 +248,13 @@ type SettingsNavigatorParamList = { backTo?: Routes; }; [SCREENS.WORKSPACE.UPGRADE]: { - policyID: string; + policyID?: string; featureName?: string; backTo?: Routes; categoryId?: string; }; [SCREENS.WORKSPACE.DOWNGRADE]: { - policyID: string; + policyID?: string; }; [SCREENS.WORKSPACE.CATEGORIES_SETTINGS]: { policyID: string; diff --git a/src/pages/workspace/downgrade/DowngradeIntro.tsx b/src/pages/workspace/downgrade/DowngradeIntro.tsx index ba8c91550561..a58ea0460c1d 100644 --- a/src/pages/workspace/downgrade/DowngradeIntro.tsx +++ b/src/pages/workspace/downgrade/DowngradeIntro.tsx @@ -10,15 +10,18 @@ import useLocalize from '@hooks/useLocalize'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useThemeStyles from '@hooks/useThemeStyles'; import {openLink} from '@libs/actions/Link'; +import Navigation from '@libs/Navigation/Navigation'; import CONST from '@src/CONST'; +import ROUTES from '@src/ROUTES'; type Props = { buttonDisabled?: boolean; loading?: boolean; onDowngrade: () => void; + policyID?: string; }; -function DowngradeIntro({onDowngrade, buttonDisabled, loading}: Props) { +function DowngradeIntro({onDowngrade, buttonDisabled, loading, policyID}: Props) { const styles = useThemeStyles(); const {translate} = useLocalize(); const {environmentURL} = useEnvironment(); @@ -67,14 +70,23 @@ function DowngradeIntro({onDowngrade, buttonDisabled, loading}: Props) { {translate('workspace.downgrade.commonFeatures.benefits.warning')}
-