diff --git a/assets/images/integrationicons/exact-online-icon-square.svg b/assets/images/integrationicons/exact-online-icon-square.svg new file mode 100644 index 000000000000..104d1617da63 --- /dev/null +++ b/assets/images/integrationicons/exact-online-icon-square.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/CONST/index.ts b/src/CONST/index.ts index f1bb8ae3e70d..d6e185ba7ff5 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -1192,6 +1192,7 @@ const CONST = { BULK_SUBMIT_APPROVE_PAY: 'bulkSubmitApprovePay', VENDOR_MATCHING: 'vendorMatching', BUSINESS_CENTRAL: 'businessCentral', + EXACT_ONLINE: 'exactOnline2026', COMMUTER_EXCLUSIONS: 'commuterExclusions', COMMUTER_EXCLUSIONS_ARRANGEMENTS: 'commuterExclusionsArrangements', MULTIPLE_APPROVERS: 'multipleApprovers', @@ -5031,6 +5032,7 @@ const CONST = { CAMPFIRE: 'campfire', ZOHO_BOOKS: 'zohoBooks', BUSINESS_CENTRAL: 'businessCentral', + EXACT_ONLINE: 'exactOnline', GUSTO: 'gusto', ZENEFITS: 'zenefits', MERGE_HR: 'merge_hris', @@ -5069,6 +5071,7 @@ const CONST = { campfire: 'Campfire', zohoBooks: 'Zoho Books', businessCentral: 'Dynamics 365 Business Central', + exactOnline: 'Exact Online', gusto: 'Gusto', billCom: 'Bill.com', zenefits: 'TriNet', @@ -10267,6 +10270,7 @@ const CONST = { }, ACCOUNTING: { SETUP_BUTTON: 'WorkspaceAccounting-SetupButton', + EXACT_ONLINE_WAITLIST_BUTTON: 'WorkspaceAccounting-ExactOnlineWaitlistButton', CARD_SECTION_ADD_BUTTON: 'WorkspaceAccounting-CardSectionAddButton', THREE_DOT_MENU: 'WorkspaceAccounting-ThreeDotMenu', }, diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts index e1195cb87f7f..ecc06d0999c3 100755 --- a/src/ONYXKEYS.ts +++ b/src/ONYXKEYS.ts @@ -196,6 +196,9 @@ const ONYXKEYS = { /** NVP keys */ + /** UTC signup timestamp for the account's Exact Online waitlist membership */ + NVP_EXACT_ONLINE_WAITLIST: 'nvp_expensify_exactOnlineWaitlist', + /** This NVP contains list of at most 5 recent attendees */ NVP_RECENT_ATTENDEES: 'nvp_expensify_recentAttendees', @@ -1784,6 +1787,7 @@ type OnyxValuesMapping = { // The value of this nvp is a string representation of the date when the block expires, or an empty string if the user is not blocked [ONYXKEYS.NVP_BLOCKED_FROM_CHAT]: string; [ONYXKEYS.NVP_PRIVATE_PUSH_NOTIFICATION_ID]: string; + [ONYXKEYS.NVP_EXACT_ONLINE_WAITLIST]: string; [ONYXKEYS.NVP_RECENT_ATTENDEES]: Attendee[]; [ONYXKEYS.NVP_TRY_FOCUS_MODE]: boolean; [ONYXKEYS.NVP_DISMISSED_HOLD_USE_EXPLANATION]: boolean; diff --git a/src/components/Icon/chunks/expensify-icons.chunk.ts b/src/components/Icon/chunks/expensify-icons.chunk.ts index 48cf25d3c3cd..a07fa8396598 100644 --- a/src/components/Icon/chunks/expensify-icons.chunk.ts +++ b/src/components/Icon/chunks/expensify-icons.chunk.ts @@ -145,6 +145,7 @@ import CircleSlash from '@assets/images/integrationicons/circle-slash.svg'; import ClaudeSquare from '@assets/images/integrationicons/claude-icon-square.svg'; import CursorSquare from '@assets/images/integrationicons/cursor-icon-square.svg'; import DualEntrySquare from '@assets/images/integrationicons/dualentry-icon-square.svg'; +import ExactOnlineSquare from '@assets/images/integrationicons/exact-online-icon-square.svg'; import NetSuiteExport from '@assets/images/integrationicons/export/netsuite-icon.svg'; import QBOExport from '@assets/images/integrationicons/export/qbo-icon.svg'; import SageIntacctExport from '@assets/images/integrationicons/export/sage-intacct-icon.svg'; @@ -505,6 +506,7 @@ const Expensicons = { ReceiptMultiple, RilletSquare, DualEntrySquare, + ExactOnlineSquare, Rotate, RotateLeft, Scan, diff --git a/src/languages/TranslationTypes.ts b/src/languages/TranslationTypes.ts index 2ff1b97b0a59..22b6b4eaacc7 100644 --- a/src/languages/TranslationTypes.ts +++ b/src/languages/TranslationTypes.ts @@ -73,7 +73,8 @@ type PersonalRulesModifiedFields = PolicyRulesModifiedFields & { reportName?: string; }; -type ConnectionName = ValueOf; +// Exact Online only has a waitlist until its connector is released. +type ConnectionName = Exclude, typeof CONST.POLICY.CONNECTIONS.NAME.EXACT_ONLINE>; type AllConnectionName = ConnectionName; type PolicyConnectionSyncStage = ValueOf; type SageIntacctMappingName = ValueOf; diff --git a/src/languages/de.ts b/src/languages/de.ts index 71ea8fde1729..4f3f6b050539 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -7468,6 +7468,12 @@ Der Control-Tarif beginnt bei 9 $ pro aktivem Mitglied und Monat.`, errorODIntegration: (oldDotPolicyConnectionsURL: string) => `Bei einer in Expensify Classic eingerichteten Verbindung ist ein Fehler aufgetreten. [Wechsle zu Expensify Classic, um dieses Problem zu beheben.](${oldDotPolicyConnectionsURL})`, goToODToSettings: 'Wechsle zu Expensify Classic, um deine Einstellungen zu verwalten.', + exactOnline: { + comingSoon: 'Demnächst verfügbar', + joinWaitlist: 'Warteliste beitreten', + onWaitlist: 'Sie stehen auf der Warteliste', + waitlistDescription: 'Concierge sendet Ihnen eine Nachricht, sobald diese Integration bereit ist, und erklärt Ihnen, wie Sie die Verbindung herstellen.', + }, setup: 'Verbinden', connectToSandbox: 'Mit Sandbox verbinden', lastSync: (relativeDate: string) => `Zuletzt synchronisiert ${relativeDate}`, diff --git a/src/languages/el.ts b/src/languages/el.ts index 81c2f58d47a6..50cd76d2a45c 100644 --- a/src/languages/el.ts +++ b/src/languages/el.ts @@ -7601,6 +7601,12 @@ _Για πιο αναλυτικές οδηγίες, [επισκεφθείτε τ errorODIntegration: (oldDotPolicyConnectionsURL: string) => `Προέκυψε σφάλμα με μια σύνδεση που έχει ρυθμιστεί στο Expensify Classic. [Μεταβείτε στο Expensify Classic για να διορθώσετε αυτό το ζήτημα.](${oldDotPolicyConnectionsURL})`, goToODToSettings: 'Μεταβείτε στο Expensify Classic για να διαχειριστείτε τις ρυθμίσεις σας.', + exactOnline: { + comingSoon: 'Έρχεται σύντομα', + joinWaitlist: 'Εγγραφείτε στη λίστα αναμονής', + onWaitlist: 'Είστε σε λίστα αναμονής', + waitlistDescription: 'Ο Concierge θα σας στείλει ένα μήνυμα για να σας ενημερώσει πότε θα είναι έτοιμη αυτή η ενσωμάτωση και πώς να τη συνδέσετε.', + }, setup: 'Σύνδεση', connectToSandbox: 'Σύνδεση στο sandbox', lastSync: (relativeDate: string) => `Τελευταίος συγχρονισμός ${relativeDate}`, diff --git a/src/languages/en.ts b/src/languages/en.ts index 19cbc7fcd8d3..c2113c447719 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -7865,6 +7865,12 @@ const translations = { errorODIntegration: (oldDotPolicyConnectionsURL: string) => `There's an error with a connection that's been set up in Expensify Classic. [Go to Expensify Classic to fix this issue.](${oldDotPolicyConnectionsURL})`, goToODToSettings: 'Go to Expensify Classic to manage your settings.', + exactOnline: { + comingSoon: 'Coming soon', + joinWaitlist: 'Join waitlist', + onWaitlist: 'You’re on the waitlist', + waitlistDescription: 'Concierge will send you a message to let you know when this integration is ready and how to connect.', + }, setup: 'Connect', connectToSandbox: 'Connect to sandbox', lastSync: (relativeDate: string) => `Last synced ${relativeDate}`, diff --git a/src/languages/es.ts b/src/languages/es.ts index ce510f051660..7b23e0e9a3b4 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -7266,6 +7266,12 @@ El plan Controlar empieza en 9 $ por miembro activo al mes.`, errorODIntegration: (oldDotPolicyConnectionsURL) => `Hay un error con una conexión que se ha configurado en Expensify Classic. [Ve a Expensify Classic para solucionar este problema.](${oldDotPolicyConnectionsURL})`, goToODToSettings: 'Ve a Expensify Classic para gestionar tus configuraciones.', + exactOnline: { + comingSoon: 'Próximamente', + joinWaitlist: 'Unirse a la lista de espera', + onWaitlist: 'Estás en la lista de espera', + waitlistDescription: 'Concierge te enviará un mensaje para avisarte cuando esta integración esté lista y cómo conectarla.', + }, setup: 'Configurar', connectToSandbox: 'Conectar con Sandbox', lastSync: (relativeDate) => `Recién sincronizado ${relativeDate}`, diff --git a/src/languages/fr.ts b/src/languages/fr.ts index a6a9b9ca24fb..a4006f19a98a 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -7488,6 +7488,12 @@ Le forfait Control commence à 9 $ par Membre actif et par mois.`, errorODIntegration: (oldDotPolicyConnectionsURL: string) => `Une erreur s’est produite avec une connexion configurée dans Expensify Classic. [Accédez à Expensify Classic pour corriger ce problème.](${oldDotPolicyConnectionsURL})`, goToODToSettings: 'Accédez à Expensify Classic pour gérer vos paramètres.', + exactOnline: { + comingSoon: 'Bientôt disponible', + joinWaitlist: 'Rejoindre la liste d’attente', + onWaitlist: 'Vous êtes sur la liste d’attente', + waitlistDescription: 'Concierge vous enverra un message pour vous informer lorsque cette intégration sera prête et vous expliquer comment la connecter.', + }, setup: 'Connecter', connectToSandbox: 'Se connecter à Sandbox', lastSync: (relativeDate: string) => `Dernière synchronisation ${relativeDate}`, diff --git a/src/languages/it.ts b/src/languages/it.ts index 769669d5b813..75865b8517c4 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -7425,6 +7425,12 @@ Il piano Control parte da 9 $ al mese per ogni membro attivo.`, errorODIntegration: (oldDotPolicyConnectionsURL: string) => `Si è verificato un errore con una connessione configurata in Expensify Classic. [Vai a Expensify Classic per risolvere questo problema.](${oldDotPolicyConnectionsURL})`, goToODToSettings: 'Vai a Expensify Classic per gestire le tue impostazioni.', + exactOnline: { + comingSoon: 'Presto in arrivo', + joinWaitlist: 'Unisciti alla lista d’attesa', + onWaitlist: 'Sei in lista d’attesa', + waitlistDescription: 'Concierge ti invierà un messaggio per farti sapere quando questa integrazione sarà pronta e come collegarla.', + }, setup: 'Connetti', connectToSandbox: 'Connetti alla sandbox', lastSync: (relativeDate: string) => `Ultima sincronizzazione ${relativeDate}`, diff --git a/src/languages/ja.ts b/src/languages/ja.ts index fccf6ada6df2..93a05b64385a 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -7334,6 +7334,12 @@ Control プランは、アクティブメンバー1人あたり月額 $9 から errorODIntegration: (oldDotPolicyConnectionsURL: string) => `Expensify Classic で設定されている接続にエラーがあります。[この問題を解決するには Expensify Classic に移動してください。](${oldDotPolicyConnectionsURL})`, goToODToSettings: '設定を管理するには、Expensify Classic に移動してください。', + exactOnline: { + comingSoon: '近日公開予定', + joinWaitlist: 'ウェイトリストに参加', + onWaitlist: '順番待ちリストに登録されています', + waitlistDescription: 'この連携の準備が整い、接続方法が決まりましたら、Concierge からメッセージでお知らせします。', + }, setup: '接続', connectToSandbox: 'Sandbox に接続', lastSync: (relativeDate: string) => `最終同期:${relativeDate}`, diff --git a/src/languages/nl.ts b/src/languages/nl.ts index 17501a00c19c..13b8e5df07a1 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -7408,6 +7408,12 @@ Het Control-abonnement begint bij $9 per actieve deelnemer per maand.`, errorODIntegration: (oldDotPolicyConnectionsURL: string) => `Er is een fout opgetreden met een verbinding die is ingesteld in Expensify Classic. [Ga naar Expensify Classic om dit probleem op te lossen.](${oldDotPolicyConnectionsURL})`, goToODToSettings: 'Ga naar Expensify Classic om je instellingen te beheren.', + exactOnline: { + comingSoon: 'Binnenkort beschikbaar', + joinWaitlist: 'Aanmelden voor wachtlijst', + onWaitlist: 'Je staat op de wachtlijst', + waitlistDescription: 'Concierge stuurt je een bericht om je te laten weten wanneer deze integratie klaar is en hoe je verbinding kunt maken.', + }, setup: 'Verbinden', connectToSandbox: 'Verbinden met sandbox', lastSync: (relativeDate: string) => `Laatst gesynchroniseerd ${relativeDate}`, diff --git a/src/languages/pl.ts b/src/languages/pl.ts index fdebdb2ec306..f0ff574fc921 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -7434,6 +7434,12 @@ Plan Control zaczyna się od 9 USD za aktywnego członka miesięcznie.`, errorODIntegration: (oldDotPolicyConnectionsURL: string) => `Wystąpił błąd z połączeniem skonfigurowanym w Expensify Classic. [Przejdź do Expensify Classic, aby rozwiązać ten problem.](${oldDotPolicyConnectionsURL})`, goToODToSettings: 'Przejdź do Expensify Classic, aby zarządzać swoimi ustawieniami.', + exactOnline: { + comingSoon: 'Wkrótce dostępne', + joinWaitlist: 'Dołącz do listy oczekujących', + onWaitlist: 'Jesteś na liście oczekujących', + waitlistDescription: 'Concierge wyśle ci wiadomość, żeby dać znać, kiedy ta integracja będzie gotowa i jak ją połączyć.', + }, setup: 'Połącz', connectToSandbox: 'Połącz z sandboxem', lastSync: (relativeDate: string) => `Ostatnia synchronizacja ${relativeDate}`, diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index 4fcc3490687d..9f80a02c72ed 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -7410,6 +7410,12 @@ O plano Control começa em US$ 9 por membro ativo por mês.`, errorODIntegration: (oldDotPolicyConnectionsURL: string) => `Há um erro em uma conexão configurada no Expensify Classic. [Acesse o Expensify Classic para corrigir esse problema.](${oldDotPolicyConnectionsURL})`, goToODToSettings: 'Vá para o Expensify Classic para gerenciar suas configurações.', + exactOnline: { + comingSoon: 'Em breve', + joinWaitlist: 'Entrar na lista de espera', + onWaitlist: 'Você está na lista de espera', + waitlistDescription: 'O Concierge vai enviar uma mensagem para avisar quando essa integração estiver pronta e como conectá-la.', + }, setup: 'Conectar', connectToSandbox: 'Conectar ao sandbox', lastSync: (relativeDate: string) => `Última sincronização ${relativeDate}`, diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index 39a6816c5c9f..eb0422c556ec 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -7148,6 +7148,12 @@ _如需更详细的说明,请[访问我们的帮助网站](${CONST.NETSUITE_IM }, errorODIntegration: (oldDotPolicyConnectionsURL: string) => `Expensify Classic 中已设置的某个连接出错。[前往 Expensify Classic 解决此问题](${oldDotPolicyConnectionsURL})`, goToODToSettings: '前往 Expensify Classic 管理您的设置。', + exactOnline: { + comingSoon: '即将推出', + joinWaitlist: '加入候补名单', + onWaitlist: '您在候补名单中', + waitlistDescription: '当此集成准备就绪并可连接时,Concierge 会向你发送消息通知你并说明如何连接。', + }, setup: '连接', connectToSandbox: '连接到沙盒', lastSync: (relativeDate: string) => `上次同步于 ${relativeDate}`, diff --git a/src/libs/ExportOnyxState/masking.ts b/src/libs/ExportOnyxState/masking.ts index 2c47235d909f..b8d41b3d78c9 100644 --- a/src/libs/ExportOnyxState/masking.ts +++ b/src/libs/ExportOnyxState/masking.ts @@ -294,6 +294,7 @@ const safeOnyxKeys = new Set([ ONYXKEYS.NVP_DISMISSED_REJECT_USE_EXPLANATION, ONYXKEYS.NVP_DISMISSED_TRIAL_PAYMENT_REMINDER, ONYXKEYS.NVP_EMPTY_REPORTS_CONFIRMATION_DISMISSED, + ONYXKEYS.NVP_EXACT_ONLINE_WAITLIST, ONYXKEYS.NVP_HAS_SEEDED_MY_EXPENSES_SEARCH, ONYXKEYS.NVP_HAS_SEEN_FOR_YOU_TODO, ONYXKEYS.NVP_HAS_SEEN_TRACK_TRAINING, diff --git a/src/libs/actions/User.ts b/src/libs/actions/User.ts index c60f3ab107ba..c33541efc048 100644 --- a/src/libs/actions/User.ts +++ b/src/libs/actions/User.ts @@ -1331,6 +1331,55 @@ function setNameValuePair(name: TKey, value: SetNameValueP }); } +function joinExactOnlineWaitlist() { + const signupTimestamp = DateUtils.getDBTime(); + const optimisticData: Array> = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.ACCOUNT, + value: { + pendingFields: {exactOnlineWaitlist: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD}, + errorFields: {exactOnlineWaitlist: null}, + }, + }, + ]; + // The confirmation modal must not treat a queued signup as confirmed membership. + const successData: Array> = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.NVP_EXACT_ONLINE_WAITLIST, + value: signupTimestamp, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.ACCOUNT, + value: {pendingFields: {exactOnlineWaitlist: null}}, + }, + ]; + const failureData: Array> = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.NVP_EXACT_ONLINE_WAITLIST, + value: '', + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.ACCOUNT, + value: { + pendingFields: {exactOnlineWaitlist: null}, + errorFields: {exactOnlineWaitlist: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('common.genericErrorMessage')}, + }, + }, + ]; + + // The queued request keeps this timestamp when it retries, preserving the original signup time. + API.write(WRITE_COMMANDS.SET_NAME_VALUE_PAIR, {name: ONYXKEYS.NVP_EXACT_ONLINE_WAITLIST, value: signupTimestamp}, {optimisticData, successData, failureData}); +} + +function clearExactOnlineWaitlistError() { + Onyx.merge(ONYXKEYS.ACCOUNT, {errorFields: {exactOnlineWaitlist: null}}); +} + function dismissMarketingWindow(updateKey: string) { const optimisticData: AnyOnyxUpdate[] = [ { @@ -2039,6 +2088,8 @@ export { clearDraftCustomStatus, requestRefund, setNameValuePair, + joinExactOnlineWaitlist, + clearExactOnlineWaitlistError, setSubmitMigrationModalShown, clearUnvalidatedNewContactMethodAction, clearPendingContactActionErrors, diff --git a/src/pages/workspace/accounting/ExactOnlineWaitlistButton.tsx b/src/pages/workspace/accounting/ExactOnlineWaitlistButton.tsx new file mode 100644 index 000000000000..8a3eca80219d --- /dev/null +++ b/src/pages/workspace/accounting/ExactOnlineWaitlistButton.tsx @@ -0,0 +1,90 @@ +/** Account-wide signup for Exact Online before the accounting connector is available. */ +import Button from '@components/Button'; +import Text from '@components/Text'; + +import useConfirmModal from '@hooks/useConfirmModal'; +import useLocalize from '@hooks/useLocalize'; +import useOnyx from '@hooks/useOnyx'; +import usePolicyFeatureWriteAccess from '@hooks/usePolicyFeatureWriteAccess'; +import useThemeStyles from '@hooks/useThemeStyles'; + +import {joinExactOnlineWaitlist} from '@userActions/User'; + +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {Policy} from '@src/types/onyx'; + +import type {OnyxEntry} from 'react-native-onyx'; + +import React, {useEffect, useRef} from 'react'; + +type ExactOnlineWaitlistButtonProps = { + policy: OnyxEntry; +}; + +function ExactOnlineWaitlistButton({policy}: ExactOnlineWaitlistButtonProps) { + const styles = useThemeStyles(); + const {translate} = useLocalize(); + const {showConfirmModal} = useConfirmModal(); + const {canWrite, showReadOnlyModal} = usePolicyFeatureWriteAccess(policy, CONST.POLICY.POLICY_FEATURE.ACCOUNTING); + const [signupTimestamp, signupTimestampMetadata] = useOnyx(ONYXKEYS.NVP_EXACT_ONLINE_WAITLIST); + const [pendingAction, pendingActionMetadata] = useOnyx(ONYXKEYS.ACCOUNT, {selector: (account) => account?.pendingFields?.exactOnlineWaitlist}); + const [errors] = useOnyx(ONYXKEYS.ACCOUNT, {selector: (account) => account?.errorFields?.exactOnlineWaitlist}); + const isJoiningWaitlist = useRef(false); + + // Queued writes finish through Onyx, including retries after the press handler has returned. + useEffect(() => { + if (!isJoiningWaitlist.current || pendingAction || pendingActionMetadata.status === 'loading') { + return; + } + if (errors) { + isJoiningWaitlist.current = false; + return; + } + if (!signupTimestamp) { + return; + } + + isJoiningWaitlist.current = false; + showConfirmModal({ + title: translate('workspace.accounting.exactOnline.onWaitlist'), + prompt: translate('workspace.accounting.exactOnline.waitlistDescription'), + confirmText: translate('common.buttonConfirm'), + shouldShowCancelButton: false, + }); + }, [errors, pendingAction, pendingActionMetadata.status, showConfirmModal, signupTimestamp, translate]); + + if (signupTimestamp && !pendingAction) { + return {translate('workspace.accounting.exactOnline.onWaitlist')}; + } + + const joinWaitlist = () => { + if (isJoiningWaitlist.current || pendingAction) { + return; + } + if (!canWrite) { + showReadOnlyModal(); + return; + } + // Prevent repeated presses before the pending account state reaches this component. + isJoiningWaitlist.current = true; + joinExactOnlineWaitlist(); + }; + + return ( + + ); +} + +export default ExactOnlineWaitlistButton; diff --git a/src/pages/workspace/accounting/ExactOnlineWaitlistRow.tsx b/src/pages/workspace/accounting/ExactOnlineWaitlistRow.tsx new file mode 100644 index 000000000000..4c3a21376252 --- /dev/null +++ b/src/pages/workspace/accounting/ExactOnlineWaitlistRow.tsx @@ -0,0 +1,85 @@ +/** Shared Exact Online identity and signup state for Accounting and Connections. */ +import UserAvatar from '@components/Avatar/UserAvatar'; +import Badge from '@components/Badge'; +import MenuItem from '@components/MenuItem'; +import OfflineWithFeedback from '@components/OfflineWithFeedback'; + +import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; +import useLocalize from '@hooks/useLocalize'; +import useOnyx from '@hooks/useOnyx'; +import useThemeStyles from '@hooks/useThemeStyles'; + +import getPlatform from '@libs/getPlatform'; + +import {clearExactOnlineWaitlistError} from '@userActions/User'; + +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {Policy} from '@src/types/onyx'; + +import type {StyleProp, ViewStyle} from 'react-native'; +import type {OnyxEntry} from 'react-native-onyx'; + +import React from 'react'; +import {View} from 'react-native'; + +import ExactOnlineWaitlistButton from './ExactOnlineWaitlistButton'; + +type ExactOnlineWaitlistRowProps = { + policy: OnyxEntry; + style?: StyleProp; + titleWrapperStyle?: StyleProp; + isBadgeCondensed?: boolean; +}; + +function ExactOnlineWaitlistRow({policy, style, titleWrapperStyle, isBadgeCondensed}: ExactOnlineWaitlistRowProps) { + const styles = useThemeStyles(); + const {translate} = useLocalize(); + const icons = useMemoizedLazyExpensifyIcons(['ExactOnlineSquare']); + const [signupTimestamp] = useOnyx(ONYXKEYS.NVP_EXACT_ONLINE_WAITLIST); + const [errors] = useOnyx(ONYXKEYS.ACCOUNT, {selector: (account) => account?.errorFields?.exactOnlineWaitlist}); + const [pendingAction] = useOnyx(ONYXKEYS.ACCOUNT, {selector: (account) => account?.pendingFields?.exactOnlineWaitlist}); + const title = CONST.POLICY.CONNECTIONS.NAME_USER_FRIENDLY.exactOnline; + const comingSoon = translate('workspace.accounting.exactOnline.comingSoon'); + const accessibilityLabel = [title, comingSoon, signupTimestamp && !pendingAction && translate('workspace.accounting.exactOnline.onWaitlist')].filter(Boolean).join(', '); + + return ( + + + + + + + + + {title} + + + + + + + + + + ); +} + +export default ExactOnlineWaitlistRow; diff --git a/src/pages/workspace/accounting/PolicyAccountingPage.tsx b/src/pages/workspace/accounting/PolicyAccountingPage.tsx index 76519f4b39af..90df82a52eae 100644 --- a/src/pages/workspace/accounting/PolicyAccountingPage.tsx +++ b/src/pages/workspace/accounting/PolicyAccountingPage.tsx @@ -47,6 +47,7 @@ import {View} from 'react-native'; import type {MenuItemData, PolicyAccountingPageProps} from './types'; import {AccountingContextProvider, useAccountingActions, useAccountingState} from './AccountingContext'; +import ExactOnlineWaitlistRow from './ExactOnlineWaitlistRow'; import useConnectedAccountingIntegration from './useConnectedAccountingIntegration'; type RouteParams = { @@ -398,6 +399,11 @@ function PolicyAccountingPage({policy}: PolicyAccountingPageProps) { /> )} + {!!account?.guideDetails?.email && !hasAccountingConnections(policy) && canWriteAccounting && ( - {listings.map((listing) => ( - - ))} + {listings.map((listing) => + listing.key === CONST.POLICY.CONNECTIONS.NAME.EXACT_ONLINE ? ( + + ) : ( + + ), + )} {!shouldStackCards && Array.from({length: FILLER_COUNT}, (_, index) => ( + + + ); +} + +export default ExactOnlineWaitlistCard; diff --git a/src/pages/workspace/connections/WorkspaceConnectionsPage.tsx b/src/pages/workspace/connections/WorkspaceConnectionsPage.tsx index 7c1dba68a69d..ea73b8b996d4 100644 --- a/src/pages/workspace/connections/WorkspaceConnectionsPage.tsx +++ b/src/pages/workspace/connections/WorkspaceConnectionsPage.tsx @@ -299,7 +299,10 @@ function WorkspaceConnectionsPage({policy}: WithPolicyConnectionsProps) { > {connectedListings.length > 0 && ( - + )} 0 ? styles.mt6 : styles.mt3, !shouldUseSingleColumn && [styles.flexRow, styles.alignItemsCenter, styles.gap5]]}> @@ -340,7 +343,10 @@ function WorkspaceConnectionsPage({policy}: WithPolicyConnectionsProps) { /> ) : ( - + )} {!shouldShowEmptyState && ( diff --git a/src/pages/workspace/connections/types.ts b/src/pages/workspace/connections/types.ts index 23f2b9381c27..a2fa41dd0ef6 100644 --- a/src/pages/workspace/connections/types.ts +++ b/src/pages/workspace/connections/types.ts @@ -35,7 +35,7 @@ type ConnectionListing = { /** Only set while the integration is connected to the workspace */ status?: ConnectionStatus; - onConnect: () => void; + onConnect?: () => void; /** Shows an "Offer" badge for a partner promotion. The badge only opens the offer when `onPress` is set. */ offer?: {onPress?: () => void}; diff --git a/src/pages/workspace/connections/useAccountingConnectionListings.ts b/src/pages/workspace/connections/useAccountingConnectionListings.ts index a213688c9911..6a630d1ef2f3 100644 --- a/src/pages/workspace/connections/useAccountingConnectionListings.ts +++ b/src/pages/workspace/connections/useAccountingConnectionListings.ts @@ -120,7 +120,7 @@ function useAccountingConnectionListings(policy: OnyxEntry): ConnectionL ...(name === CONST.POLICY.CONNECTIONS.NAME.QBO ? [{name, isIntuitEnterpriseSuite: true}] : []), ]); - return integrationOptions.flatMap(({name, isIntuitEnterpriseSuite}) => { + const listings: ConnectionListing[] = integrationOptions.flatMap(({name, isIntuitEnterpriseSuite}) => { const integrationData = getAccountingIntegrationData(name, policyID, translate, NO_REUSABLE_CONNECTIONS, { expensifyIcons: accountingIcons, isIntuitEnterpriseSuiteOverride: isIntuitEnterpriseSuite, @@ -155,6 +155,14 @@ function useAccountingConnectionListings(policy: OnyxEntry): ConnectionL }, }; }); + + // The connector flow arrives in a later release, so beta users also see the waitlist for now. + listings.push({ + key: CONST.POLICY.CONNECTIONS.NAME.EXACT_ONLINE, + category: CONST.TAB.CONNECTIONS.ACCOUNTING, + title: CONST.POLICY.CONNECTIONS.NAME_USER_FRIENDLY.exactOnline, + }); + return listings; } export default useAccountingConnectionListings; diff --git a/src/types/onyx/Account.ts b/src/types/onyx/Account.ts index 8f8683689812..189416870ce3 100644 --- a/src/types/onyx/Account.ts +++ b/src/types/onyx/Account.ts @@ -136,6 +136,9 @@ type Account = { /** Errors related to specific account fields */ errorFields?: OnyxCommon.ErrorFields; + /** Pending account changes while requests are queued. */ + pendingFields?: OnyxCommon.PendingFields<'exactOnlineWaitlist'>; + /** Authentication success message */ success?: string; diff --git a/tests/actions/UserTest.ts b/tests/actions/UserTest.ts index 9c0a92698f4b..fa52414a556a 100644 --- a/tests/actions/UserTest.ts +++ b/tests/actions/UserTest.ts @@ -1,6 +1,7 @@ import * as ActiveClientManager from '@libs/ActiveClientManager'; import * as API from '@libs/API'; import {READ_COMMANDS, SIDE_EFFECT_REQUEST_COMMANDS, WRITE_COMMANDS} from '@libs/API/types'; +import DateUtils from '@libs/DateUtils'; import {getLatestErrorField} from '@libs/ErrorUtils'; import Log from '@libs/Log'; import * as SequentialQueue from '@libs/Network/SequentialQueue'; @@ -45,6 +46,52 @@ describe('actions/User', () => { return Onyx.clear().then(waitForBatchedUpdates); }); + describe('joinExactOnlineWaitlist', () => { + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('queues the signup timestamp with a rollback to the absent value', () => { + // Given the account has not joined the waitlist + const signupTimestamp = '2026-10-14 10:00:00.000'; + const errorTimestamp = 1; + const getDBTime = jest.spyOn(DateUtils, 'getDBTime').mockReturnValue(signupTimestamp); + jest.spyOn(DateUtils, 'getMicroseconds').mockReturnValue(errorTimestamp); + + // When the account joins the waitlist + UserActions.joinExactOnlineWaitlist(); + + // Then the queued request and confirmed signup share one timestamp, with the previous value restored on failure + expect(getDBTime).toHaveBeenCalledTimes(1); + expect(mockAPI.write).toHaveBeenCalledTimes(1); + expect(mockAPI.write).toHaveBeenCalledWith( + WRITE_COMMANDS.SET_NAME_VALUE_PAIR, + {name: ONYXKEYS.NVP_EXACT_ONLINE_WAITLIST, value: signupTimestamp}, + { + optimisticData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.ACCOUNT, + value: {pendingFields: {exactOnlineWaitlist: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD}, errorFields: {exactOnlineWaitlist: null}}, + }, + ], + successData: [ + {onyxMethod: Onyx.METHOD.MERGE, key: ONYXKEYS.NVP_EXACT_ONLINE_WAITLIST, value: signupTimestamp}, + {onyxMethod: Onyx.METHOD.MERGE, key: ONYXKEYS.ACCOUNT, value: {pendingFields: {exactOnlineWaitlist: null}}}, + ], + failureData: [ + {onyxMethod: Onyx.METHOD.MERGE, key: ONYXKEYS.NVP_EXACT_ONLINE_WAITLIST, value: ''}, + { + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.ACCOUNT, + value: {pendingFields: {exactOnlineWaitlist: null}, errorFields: {exactOnlineWaitlist: {[errorTimestamp]: anyString}}}, + }, + ], + }, + ); + }); + }); + describe('clearContactMethod', () => { it('should return early when contactMethods array is empty', async () => { // Given an empty array diff --git a/tests/unit/ExactOnlineConnectionListingTest.tsx b/tests/unit/ExactOnlineConnectionListingTest.tsx new file mode 100644 index 000000000000..c2e852ab1567 --- /dev/null +++ b/tests/unit/ExactOnlineConnectionListingTest.tsx @@ -0,0 +1,107 @@ +import {act, fireEvent, render, renderHook, screen} from '@testing-library/react-native'; + +import ComposeProviders from '@components/ComposeProviders'; +import {LocaleContextProvider} from '@components/LocaleContextProvider'; +import OnyxListItemProvider from '@components/OnyxListItemProvider'; + +import ConnectionsGrid from '@pages/workspace/connections/ConnectionsGrid'; +import useAccountingConnectionListings from '@pages/workspace/connections/useAccountingConnectionListings'; + +import {clearExactOnlineWaitlistError, joinExactOnlineWaitlist} from '@userActions/User'; + +import CONST from '@src/CONST'; +import IntlStore from '@src/languages/IntlStore'; +import ONYXKEYS from '@src/ONYXKEYS'; + +import React from 'react'; +import Onyx from 'react-native-onyx'; + +import type * as MockUseConfirmModalUtil from '../utils/mockUseConfirmModal'; + +import createRandomPolicy from '../utils/collections/policies'; +import waitForBatchedUpdates from '../utils/waitForBatchedUpdates'; +import waitForBatchedUpdatesWithAct from '../utils/waitForBatchedUpdatesWithAct'; + +let mockEnabledBetas: string[] = []; +const mockStartIntegrationFlow = jest.fn(); +const mockConnect = jest.fn(); + +jest.mock('@userActions/User', () => ({clearExactOnlineWaitlistError: jest.fn(), joinExactOnlineWaitlist: jest.fn()})); +jest.mock('@hooks/useConfirmModal', () => { + const {default: mockUseConfirmModal} = jest.requireActual('../utils/mockUseConfirmModal'); + return mockUseConfirmModal; +}); +jest.mock('@hooks/usePermissions', () => ({ + __esModule: true, + default: () => ({isBetaEnabled: (beta: string) => mockEnabledBetas.includes(beta)}), +})); +jest.mock('@hooks/useIsUnifiedConnectionsBetaEnabled', () => () => true); +jest.mock('@hooks/usePolicyFeatureWriteAccess', () => ({ + __esModule: true, + default: () => ({canWrite: true, showReadOnlyModal: jest.fn()}), +})); +jest.mock('@pages/workspace/accounting/AccountingContext', () => ({ + useAccountingActions: () => ({startIntegrationFlow: mockStartIntegrationFlow}), + useAccountingState: () => ({activeIntegration: undefined, popoverAnchorRefs: {current: {}}}), +})); + +function TestWrapper({children}: {children: React.ReactNode}) { + return {children}; +} + +describe('Exact Online in unified Connections', () => { + beforeAll(() => { + Onyx.init({keys: ONYXKEYS}); + return IntlStore.load(CONST.LOCALES.EN); + }); + + beforeEach(async () => { + jest.clearAllMocks(); + await Onyx.clear(); + await waitForBatchedUpdates(); + }); + + it.each([{enabledBetas: []}, {enabledBetas: [CONST.BETAS.EXACT_ONLINE]}])('keeps signup available offline with Exact beta enrollment $enabledBetas', async ({enabledBetas}) => { + // Given unified Connections is enabled, the account is offline, and the Exact beta may be enabled + mockEnabledBetas = enabledBetas; + await Onyx.set(ONYXKEYS.NETWORK, {shouldForceOffline: true}); + const policy = createRandomPolicy(1, CONST.POLICY.TYPE.CORPORATE, 'Test workspace'); + await Onyx.set(`${ONYXKEYS.COLLECTION.POLICY}${policy.id}`, policy); + const {result} = renderHook(() => useAccountingConnectionListings(policy), {wrapper: TestWrapper}); + await waitForBatchedUpdatesWithAct(); + const listing = result.current.find((item) => item.key === CONST.POLICY.CONNECTIONS.NAME.EXACT_ONLINE); + if (!listing) { + throw new Error('Exact Online waitlist is missing from Connections'); + } + + // When the connection cards render and the user chooses the waitlist + render( + , + {wrapper: TestWrapper}, + ); + await waitForBatchedUpdatesWithAct(); + fireEvent.press(screen.getByRole('button', {name: 'Join waitlist'})); + fireEvent.press(screen.getByRole('button', {name: 'Existing connector', disabled: true})); + + // Then Exact offers coming-soon signup offline while live connectors retain their offline restriction + expect(screen.getByText('Coming soon')).toBeOnTheScreen(); + expect(joinExactOnlineWaitlist).toHaveBeenCalledTimes(1); + expect(mockStartIntegrationFlow).not.toHaveBeenCalled(); + expect(mockConnect).not.toHaveBeenCalled(); + expect(screen.getByLabelText('Exact Online, Coming soon')).toBeOnTheScreen(); + + await act(() => Onyx.set(ONYXKEYS.NVP_EXACT_ONLINE_WAITLIST, '2026-10-15 10:00:00')); + expect(screen.getByLabelText('Exact Online, Coming soon, You’re on the waitlist')).toBeOnTheScreen(); + + await act(() => Onyx.set(ONYXKEYS.NVP_EXACT_ONLINE_WAITLIST, null)); + expect(screen.getByLabelText('Exact Online, Coming soon')).toBeOnTheScreen(); + + await act(() => Onyx.merge(ONYXKEYS.ACCOUNT, {errorFields: {exactOnlineWaitlist: {[Date.now()]: 'Signup failed. Please try again.'}}})); + expect(screen.getByText('Signup failed. Please try again.')).toBeOnTheScreen(); + fireEvent.press(screen.getByRole('button', {name: 'Dismiss'})); + expect(clearExactOnlineWaitlistError).toHaveBeenCalledTimes(1); + }); +}); diff --git a/tests/unit/ExactOnlineWaitlistButtonTest.tsx b/tests/unit/ExactOnlineWaitlistButtonTest.tsx new file mode 100644 index 000000000000..3899f9108ff1 --- /dev/null +++ b/tests/unit/ExactOnlineWaitlistButtonTest.tsx @@ -0,0 +1,148 @@ +import {act, fireEvent, render, screen} from '@testing-library/react-native'; + +import ComposeProviders from '@components/ComposeProviders'; +import {LocaleContextProvider} from '@components/LocaleContextProvider'; +import OnyxListItemProvider from '@components/OnyxListItemProvider'; + +import ExactOnlineWaitlistButton from '@pages/workspace/accounting/ExactOnlineWaitlistButton'; + +import {joinExactOnlineWaitlist} from '@userActions/User'; + +import CONST from '@src/CONST'; +import IntlStore from '@src/languages/IntlStore'; +import ONYXKEYS from '@src/ONYXKEYS'; + +import React from 'react'; +import Onyx from 'react-native-onyx'; + +import type * as MockUseConfirmModalUtil from '../utils/mockUseConfirmModal'; + +import createRandomPolicy from '../utils/collections/policies'; +import {getShowConfirmModalOption, mockShowConfirmModal, resetMockConfirmModal} from '../utils/mockUseConfirmModal'; +import waitForBatchedUpdates from '../utils/waitForBatchedUpdates'; +import waitForBatchedUpdatesWithAct from '../utils/waitForBatchedUpdatesWithAct'; + +const mockShowReadOnlyModal = jest.fn(); +let mockCanWriteAccounting = true; +const policy = createRandomPolicy(1, CONST.POLICY.TYPE.CORPORATE, 'First workspace'); +const otherPolicy = createRandomPolicy(2, CONST.POLICY.TYPE.CORPORATE, 'Second workspace'); + +jest.mock('@userActions/User', () => ({joinExactOnlineWaitlist: jest.fn()})); +jest.mock('@hooks/useConfirmModal', () => { + const {default: mockUseConfirmModal} = jest.requireActual('../utils/mockUseConfirmModal'); + return mockUseConfirmModal; +}); +jest.mock('@hooks/usePolicyFeatureWriteAccess', () => ({ + __esModule: true, + default: () => ({canWrite: mockCanWriteAccounting, showReadOnlyModal: mockShowReadOnlyModal}), +})); + +function TestWrapper({children}: {children: React.ReactNode}) { + return {children}; +} + +describe('Exact Online waitlist signup', () => { + beforeAll(() => { + Onyx.init({keys: ONYXKEYS}); + return IntlStore.load(CONST.LOCALES.EN); + }); + + beforeEach(async () => { + jest.clearAllMocks(); + resetMockConfirmModal(); + mockCanWriteAccounting = true; + await Onyx.clear(); + await waitForBatchedUpdates(); + }); + + it('queues signup offline, confirms success once, and shares signup status across workspaces', async () => { + // Given an account that has not joined the waitlist and is currently offline + await Onyx.set(ONYXKEYS.NETWORK, {shouldForceOffline: true}); + const {rerender} = render(, {wrapper: TestWrapper}); + await waitForBatchedUpdatesWithAct(); + + // When the user presses Join waitlist twice before the optimistic value renders + const button = screen.getByRole('button', {name: 'Join waitlist'}); + fireEvent.press(button); + fireEvent.press(button); + + // Then signup is queued once without claiming success while offline + expect(joinExactOnlineWaitlist).toHaveBeenCalledTimes(1); + expect(mockShowConfirmModal).not.toHaveBeenCalled(); + + await act(() => Onyx.merge(ONYXKEYS.ACCOUNT, {pendingFields: {exactOnlineWaitlist: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD}})); + rerender(); + await waitForBatchedUpdatesWithAct(); + expect(screen.getByRole('button', {name: 'Join waitlist', disabled: true})).toBeOnTheScreen(); + + // When the request fails and its optimistic account value is rolled back + await act(() => + Onyx.update([ + {onyxMethod: Onyx.METHOD.MERGE, key: ONYXKEYS.NVP_EXACT_ONLINE_WAITLIST, value: ''}, + { + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.ACCOUNT, + value: {pendingFields: {exactOnlineWaitlist: null}, errorFields: {exactOnlineWaitlist: {[Date.now()]: 'Signup failed'}}}, + }, + ]), + ); + await waitForBatchedUpdatesWithAct(); + + // Then signup is offered again + expect(screen.getByRole('button', {name: 'Join waitlist', disabled: false})).toBeOnTheScreen(); + expect(mockShowConfirmModal).not.toHaveBeenCalled(); + + // When the retried signup succeeds and the user switches workspaces + fireEvent.press(screen.getByRole('button', {name: 'Join waitlist'})); + await act(() => Onyx.merge(ONYXKEYS.ACCOUNT, {pendingFields: {exactOnlineWaitlist: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD}, errorFields: {exactOnlineWaitlist: null}})); + await act(() => Onyx.set(ONYXKEYS.NVP_EXACT_ONLINE_WAITLIST, '2026-10-14 12:00:00')); + expect(mockShowConfirmModal).not.toHaveBeenCalled(); + await act(() => Onyx.set(ONYXKEYS.NETWORK, {shouldForceOffline: false})); + await act(() => Onyx.merge(ONYXKEYS.ACCOUNT, {pendingFields: {exactOnlineWaitlist: null}})); + rerender(); + await waitForBatchedUpdatesWithAct(); + + // Then the same account signup is shown without another action in the new workspace + expect(screen.getByText('You’re on the waitlist')).toBeTruthy(); + expect(screen.queryByRole('button', {name: 'Join waitlist'})).toBeNull(); + expect(joinExactOnlineWaitlist).toHaveBeenCalledTimes(2); + expect(mockShowConfirmModal).toHaveBeenCalledTimes(1); + expect(getShowConfirmModalOption('title')).toBe('You’re on the waitlist'); + expect(getShowConfirmModalOption('prompt')).toBe('Concierge will send you a message to let you know when this integration is ready and how to connect.'); + expect(getShowConfirmModalOption('shouldShowCancelButton')).toBe(false); + }); + + it('loads an existing account signup without offering another signup', async () => { + // Given an account that joined the waitlist in an earlier session + await Onyx.set(ONYXKEYS.NVP_EXACT_ONLINE_WAITLIST, '2026-10-13 12:00:00'); + await Onyx.merge(ONYXKEYS.ACCOUNT, {pendingFields: {exactOnlineWaitlist: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD}}); + + // When the user opens accounting in another workspace + render(, {wrapper: TestWrapper}); + await waitForBatchedUpdatesWithAct(); + expect(screen.getByRole('button', {name: 'Join waitlist', disabled: true})).toBeOnTheScreen(); + await act(() => Onyx.merge(ONYXKEYS.ACCOUNT, {pendingFields: {exactOnlineWaitlist: null}})); + await waitForBatchedUpdatesWithAct(); + + // Then their stored signup replaces the action without opening an acknowledgement + expect(screen.getByText('You’re on the waitlist')).toBeTruthy(); + expect(screen.queryByRole('button', {name: 'Join waitlist'})).toBeNull(); + expect(joinExactOnlineWaitlist).not.toHaveBeenCalled(); + expect(mockShowConfirmModal).not.toHaveBeenCalled(); + }); + + it('shows the read-only explanation without signing up when accounting write access is denied', async () => { + // Given a workspace where the user has read-only accounting access + mockCanWriteAccounting = false; + render(, {wrapper: TestWrapper}); + await waitForBatchedUpdatesWithAct(); + + // When the user presses Join waitlist + fireEvent.press(screen.getByRole('button', {name: 'Join waitlist'})); + + // Then the standard read-only explanation appears and the account stays off the waitlist + expect(mockShowReadOnlyModal).toHaveBeenCalledTimes(1); + expect(joinExactOnlineWaitlist).not.toHaveBeenCalled(); + expect(mockShowConfirmModal).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/unit/PolicyAccountingPageAutoStartFlowTest.tsx b/tests/unit/PolicyAccountingPageAutoStartFlowTest.tsx index f1b9acae0807..08fcf313e23b 100644 --- a/tests/unit/PolicyAccountingPageAutoStartFlowTest.tsx +++ b/tests/unit/PolicyAccountingPageAutoStartFlowTest.tsx @@ -1,8 +1,8 @@ -import {fireEvent, render, screen, within} from '@testing-library/react-native'; +import {act, fireEvent, render, screen, within} from '@testing-library/react-native'; import ComposeProviders from '@components/ComposeProviders'; import {LocaleContextProvider} from '@components/LocaleContextProvider'; -import type {MenuItemProps} from '@components/MenuItem'; +import type * as MenuItemModule from '@components/MenuItem'; import OnyxListItemProvider from '@components/OnyxListItemProvider'; import Navigation from '@libs/Navigation/Navigation'; @@ -11,6 +11,7 @@ import PolicyAccountingPage from '@pages/workspace/accounting/PolicyAccountingPa import CONST from '@src/CONST'; import IntlStore from '@src/languages/IntlStore'; +import ONYXKEYS from '@src/ONYXKEYS'; import type {ConnectionName} from '@src/types/onyx/Policy'; import type Policy from '@src/types/onyx/Policy'; @@ -18,6 +19,7 @@ import type * as ReactNavigation from '@react-navigation/native'; import type {View} from 'react-native'; import React from 'react'; +import Onyx from 'react-native-onyx'; import createRandomPolicy from '../utils/collections/policies'; import waitForBatchedUpdates from '../utils/waitForBatchedUpdates'; @@ -117,9 +119,10 @@ jest.mock('@components/HeaderWithBackButton', () => ({__esModule: true, default: jest.mock('@components/MenuItemList', () => ({__esModule: true, default: () => null})); jest.mock('@components/MenuItem', () => { const {View: MockView} = jest.requireActual<{View: typeof View}>('react-native'); + const {default: ActualMenuItem} = jest.requireActual('@components/MenuItem'); return { __esModule: true, - default: ({title, rightComponent}: MenuItemProps) => {rightComponent}, + default: Object.assign(({title, rightComponent}: MenuItemModule.MenuItemProps) => {rightComponent}, ActualMenuItem), }; }); jest.mock('@components/Section', () => ({__esModule: true, default: ({children}: {children: React.ReactNode}) => children})); @@ -258,4 +261,26 @@ describe('PolicyAccountingPage auto-started connect flow', () => { expect(screen.getByTestId(CONST.POLICY.CONNECTIONS.NAME_USER_FRIENDLY.businessCentral)).toBeOnTheScreen(); expect(screen.getByTestId('Campfire')).toBeOnTheScreen(); }); + + it.each([{enabledBetas: []}, {enabledBetas: [CONST.BETAS.EXACT_ONLINE]}])('offers the Exact Online waitlist with beta enrollment $enabledBetas', async ({enabledBetas}) => { + // Given an eligible workspace and a user who may have the future Exact connector beta + mockEnabledBetas = enabledBetas; + mockRouteParams = {}; + + // When the Accounting page lists its integrations + render(, {wrapper: AccountingTestWrapper}); + await waitForBatchedUpdates(); + + // Then Exact Online offers the waitlist without starting an unimplemented connector flow + expect(screen.getByText('Exact Online')).toBeOnTheScreen(); + expect(screen.getByRole('button', {name: 'Join waitlist'})).toBeOnTheScreen(); + expect(mockStartIntegrationFlow).not.toHaveBeenCalled(); + expect(screen.getByLabelText('Exact Online, Coming soon')).toBeOnTheScreen(); + + await act(() => Onyx.set(ONYXKEYS.NVP_EXACT_ONLINE_WAITLIST, '2026-10-15 10:00:00')); + expect(screen.getByLabelText('Exact Online, Coming soon, You’re on the waitlist')).toBeOnTheScreen(); + + await act(() => Onyx.set(ONYXKEYS.NVP_EXACT_ONLINE_WAITLIST, null)); + expect(screen.getByLabelText('Exact Online, Coming soon')).toBeOnTheScreen(); + }); }); diff --git a/tests/unit/useAccountingConnectionListingsTest.ts b/tests/unit/useAccountingConnectionListingsTest.ts index bdc53ce368dd..d4f06c0af32a 100644 --- a/tests/unit/useAccountingConnectionListingsTest.ts +++ b/tests/unit/useAccountingConnectionListingsTest.ts @@ -57,7 +57,7 @@ describe('useAccountingConnectionListings', () => { const xero = findListing(CONST.POLICY.CONNECTIONS.NAME.XERO); // When the admin connects Xero - xero?.onConnect(); + xero?.onConnect?.(); // Then Xero's flow starts with nothing to disconnect expect(mockStartIntegrationFlow).toHaveBeenCalledWith({name: CONST.POLICY.CONNECTIONS.NAME.XERO, isIntuitEnterpriseSuite: undefined}); @@ -69,7 +69,7 @@ describe('useAccountingConnectionListings', () => { const xero = findListing(CONST.POLICY.CONNECTIONS.NAME.XERO); // When the admin connects Xero - xero?.onConnect(); + xero?.onConnect?.(); // Then the flow is told to disconnect QuickBooks Online first, which asks the admin before replacing it expect(mockStartIntegrationFlow).toHaveBeenCalledWith({ @@ -86,7 +86,7 @@ describe('useAccountingConnectionListings', () => { const xero = findListing(CONST.POLICY.CONNECTIONS.NAME.XERO); // When they try to connect Xero - xero?.onConnect(); + xero?.onConnect?.(); // Then they are told they can't, and no flow starts expect(mockShowReadOnlyModal).toHaveBeenCalledTimes(1); diff --git a/tests/unit/useMCPConnectionListingsTest.ts b/tests/unit/useMCPConnectionListingsTest.ts index 29c2d0e01c55..21118031e957 100644 --- a/tests/unit/useMCPConnectionListingsTest.ts +++ b/tests/unit/useMCPConnectionListingsTest.ts @@ -33,7 +33,7 @@ describe('useMCPConnectionListings', () => { const claude = result.current.find((listing) => listing.key === 'claude'); // When they connect Claude - claude?.onConnect(); + claude?.onConnect?.(); // Then MCP is turned on and Claude's connect page opens, since Connections has no MCP toggle expect(enablePolicyFeatureForConnection).toHaveBeenCalledWith(policy, CONST.POLICY.MORE_FEATURES.IS_MCP_ENABLED); @@ -46,7 +46,7 @@ describe('useMCPConnectionListings', () => { const mcpGuide = result.current.find((listing) => listing.key === 'mcp'); // When the admin presses "+" on it - mcpGuide?.onConnect(); + mcpGuide?.onConnect?.(); // Then the guide opens, since the client is set up by following it rather than by connecting from here expect(openExternalLink).toHaveBeenCalledWith(CONST.MCP_HELP_URL); @@ -59,7 +59,7 @@ describe('useMCPConnectionListings', () => { const {result} = renderHook(() => useMCPConnectionListings(policy)); // When they try to connect an assistant - result.current.at(0)?.onConnect(); + result.current.at(0)?.onConnect?.(); // Then they are told they can't, and nothing is turned on or opened expect(mockShowReadOnlyModal).toHaveBeenCalledTimes(1); diff --git a/tests/unit/useMergeConnectionListingsTest.ts b/tests/unit/useMergeConnectionListingsTest.ts index 653a95419651..5fd63941924c 100644 --- a/tests/unit/useMergeConnectionListingsTest.ts +++ b/tests/unit/useMergeConnectionListingsTest.ts @@ -63,7 +63,7 @@ describe('useMergeConnectionListings', () => { const {listings, onStartSetup} = renderListings(); // When the admin connects an HR provider - listings.at(0)?.onConnect(); + listings.at(0)?.onConnect?.(); // Then the upgrade page opens instead of the provider's setup expect(Navigation.navigate).toHaveBeenCalledTimes(1); @@ -75,7 +75,7 @@ describe('useMergeConnectionListings', () => { const {listings, onStartSetup} = renderListings(); // When the admin connects Gusto - listings.find((listing) => listing.title === CONST.POLICY.CONNECTIONS.NAME.GUSTO)?.onConnect(); + listings.find((listing) => listing.title === CONST.POLICY.CONNECTIONS.NAME.GUSTO)?.onConnect?.(); // Then HR is turned on and Gusto's setup starts, with nothing to replace expect(enablePolicyFeatureForConnection).toHaveBeenCalledWith(policy, CONST.POLICY.MORE_FEATURES.IS_HR_ENABLED); @@ -91,7 +91,7 @@ describe('useMergeConnectionListings', () => { // When the admin connects another provider and cancels the Replace prompt await act(async () => { - listings.find((listing) => listing.title === CONST.POLICY.CONNECTIONS.NAME.ZENEFITS)?.onConnect(); + listings.find((listing) => listing.title === CONST.POLICY.CONNECTIONS.NAME.ZENEFITS)?.onConnect?.(); await waitForBatchedUpdates(); }); diff --git a/tests/unit/useReceiptPartnerConnectionListingsTest.ts b/tests/unit/useReceiptPartnerConnectionListingsTest.ts index b8db5cebbc8e..e00bfe80f14d 100644 --- a/tests/unit/useReceiptPartnerConnectionListingsTest.ts +++ b/tests/unit/useReceiptPartnerConnectionListingsTest.ts @@ -58,7 +58,7 @@ describe('useReceiptPartnerConnectionListings', () => { const {result} = renderHook(() => useReceiptPartnerConnectionListings(policy)); // When the admin presses "+" on Uber - act(() => result.current.at(0)?.onConnect()); + act(() => result.current.at(0)?.onConnect?.()); // Then the feature is turned on and the Uber connect page opens, since Connections has no toggle for the feature expect(enablePolicyFeatureForConnection).toHaveBeenCalledWith(policy, CONST.POLICY.MORE_FEATURES.ARE_RECEIPT_PARTNERS_ENABLED); @@ -77,7 +77,7 @@ describe('useReceiptPartnerConnectionListings', () => { // When the admin then connects Uber from the page and the connection shows up mockUberState = {...mockUberState, isUberConnected: false}; rerender({}); - act(() => result.current.at(0)?.onConnect()); + act(() => result.current.at(0)?.onConnect?.()); mockUberState = {...mockUberState, isUberConnected: true}; rerender({});