diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 88a962c4ec6d..82b5427f0dab 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -623,6 +623,7 @@ const CONST = { FNS_TIMEZONE_FORMAT_STRING: "yyyy-MM-dd'T'HH:mm:ssXXX", FNS_DB_FORMAT_STRING: 'yyyy-MM-dd HH:mm:ss.SSS', LONG_DATE_FORMAT_WITH_WEEKDAY: 'eeee, MMMM d, yyyy', + LONG_DATE_FORMAT_WITH_WEEKDAY_WITHOUT_YEAR: 'eeee, MMMM d', ORDINAL_DAY_OF_MONTH: 'do', MONTH_DAY_YEAR_ORDINAL_FORMAT: 'MMMM do, yyyy', SECONDS_PER_DAY: 24 * 60 * 60, diff --git a/src/components/Search/SearchRouter/useAskConcierge.tsx b/src/components/Search/SearchRouter/useAskConcierge.tsx index e4ba0680b438..8500cee46a4b 100644 --- a/src/components/Search/SearchRouter/useAskConcierge.tsx +++ b/src/components/Search/SearchRouter/useAskConcierge.tsx @@ -6,21 +6,26 @@ import useSidePanelReportID from '@hooks/useSidePanelReportID'; import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; -import {addComment} from '@userActions/Report'; +import {addAttachmentWithComment, addComment} from '@userActions/Report'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; +import type {FileObject} from '@src/types/utils/Attachment'; /** * Returns a callback that opens the side panel (or Concierge chat on native) * and sends the provided search query as a message. * Also returns a flag indicating whether the Ask Concierge item is ready to be displayed. + * + * @param forceConcierge Always target the Concierge report, ignoring the report the side panel currently maps to. + * Entry points whose sole purpose is messaging Concierge (e.g. the Home prompt) must set this, otherwise the message + * can be posted to the workspace #admins room the side panel maps to during the onboarding RHP variants. */ -function useAskConcierge() { +function useAskConcierge({forceConcierge = false}: {forceConcierge?: boolean} = {}) { const sidePanelReportID = useSidePanelReportID(); const [conciergeReportID] = useOnyx(ONYXKEYS.CONCIERGE_REPORT_ID); const {openConciergeAnywhere, isInSidePanel} = useOpenConciergeAnywhere(); - const targetReportID = (isInSidePanel ? sidePanelReportID : undefined) ?? conciergeReportID; + const targetReportID = forceConcierge ? conciergeReportID : ((isInSidePanel ? sidePanelReportID : undefined) ?? conciergeReportID); const [targetReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${getNonEmptyStringOnyxID(targetReportID)}`); const {timezone, accountID: currentUserAccountID} = useCurrentUserPersonalDetails(); const delegateAccountID = useDelegateAccountID(); @@ -31,7 +36,7 @@ function useAskConcierge() { if (!trimmedQuery || !shouldShowAskConcierge) { return; } - openConciergeAnywhere(); + openConciergeAnywhere({forceConcierge}); addComment({ report: targetReport, notifyReportID: targetReportID, @@ -46,7 +51,27 @@ function useAskConcierge() { }); }; - return {askConcierge, shouldShowAskConcierge}; + const askConciergeWithAttachment = (attachments: FileObject | FileObject[], searchQuery: string) => { + if (!shouldShowAskConcierge) { + return; + } + openConciergeAnywhere({forceConcierge}); + addAttachmentWithComment({ + report: targetReport, + notifyReportID: targetReportID, + ancestors: [], + attachments, + currentUserAccountID, + text: searchQuery.trim(), + timezone: timezone ?? CONST.DEFAULT_TIME_ZONE, + shouldPlaySound: true, + isInSidePanel, + delegateAccountID, + conciergeReportID, + }); + }; + + return {askConcierge, askConciergeWithAttachment, shouldShowAskConcierge, conciergeTargetReportID: targetReportID}; } export default useAskConcierge; diff --git a/src/hooks/useConciergeSidePanelReportActions.ts b/src/hooks/useConciergeSidePanelReportActions.ts index 0b9e97ec0f2c..c272c899fa45 100644 --- a/src/hooks/useConciergeSidePanelReportActions.ts +++ b/src/hooks/useConciergeSidePanelReportActions.ts @@ -180,6 +180,12 @@ function useConciergeSidePanelReportActions({ } const filtered = actions.filter(isCurrentSessionAction); if (filtered.length === 0) { + // Side panel: nothing matched the current session yet (e.g. just after reopen, before the new + // message propagates). Show the greeting instead of `actions` to avoid flashing stale history. + if (!isConciergeMainDM && conciergeGreetingAction) { + const createdAction = actions.find(isCreatedAction); + return createdAction ? [conciergeGreetingAction, createdAction] : [conciergeGreetingAction]; + } return actions; } if (conciergeGreetingAction) { @@ -188,7 +194,16 @@ function useConciergeSidePanelReportActions({ } return filtered; }, - [showConciergeSidePanelWelcome, conciergeGreetingAction, isConciergeHiddenHistory, showFullHistory, sessionStartTime, isCurrentSessionAction, hadUserMessageAtSessionStart], + [ + showConciergeSidePanelWelcome, + conciergeGreetingAction, + isConciergeHiddenHistory, + showFullHistory, + sessionStartTime, + isCurrentSessionAction, + hadUserMessageAtSessionStart, + isConciergeMainDM, + ], ); const filteredVisibleActions = useMemo(() => filterActions(visibleReportActions), [filterActions, visibleReportActions]); diff --git a/src/languages/de.ts b/src/languages/de.ts index cf8dacb2f0de..dc1340513ec6 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -1089,6 +1089,13 @@ const translations: TranslationDeepObject = { emptyStateMessage: 'Erstellen Sie eine oder ziehen Sie eine Quittung hierher', }, insightsSection: {chartUnavailable: 'Diagramm nicht verfügbar', notEnoughData: 'Wir haben noch nicht genügend Daten, um dieses Diagramm auszufüllen'}, + conciergePrompt: { + goodMorning: ({name}: {name?: string}) => (name ? `Guten Morgen, ${name}.` : 'Guten Morgen.'), + goodAfternoon: ({name}: {name?: string}) => (name ? `Guten Tag, ${name}.` : 'Guten Tag.'), + goodEvening: ({name}: {name?: string}) => (name ? `Guten Abend, ${name}.` : 'Guten Abend.'), + inputPlaceholder: 'Bitten Sie Concierge, Ihre Ausgaben zu analysieren oder Unterstützung zu erhalten', + inputPlaceholderMobile: 'Stellen Sie Concierge eine Frage', + }, }, allSettingsScreen: { subscription: 'Abonnement', diff --git a/src/languages/el.ts b/src/languages/el.ts index 5b6b8391c4d1..c48e199db716 100644 --- a/src/languages/el.ts +++ b/src/languages/el.ts @@ -1137,6 +1137,13 @@ const translations: TranslationDeepObject = { chartUnavailable: 'Το γράφημα δεν είναι διαθέσιμο', notEnoughData: 'Δεν έχουμε ακόμη αρκετά δεδομένα για να συμπληρώσουμε αυτό το γράφημα', }, + conciergePrompt: { + goodMorning: ({name}: {name?: string}) => (name ? `Καλημέρα, ${name}.` : 'Καλημέρα.'), + goodAfternoon: ({name}: {name?: string}) => (name ? `Καλησπέρα σας, ${name}.` : 'Καλό απόγευμα.'), + goodEvening: ({name}: {name?: string}) => (name ? `Καλησπέρα, ${name}.` : 'Καλησπέρα.'), + inputPlaceholder: 'Ζητήστε από το Concierge να αναλύσει τα έξοδά σας ή να λάβετε υποστήριξη', + inputPlaceholderMobile: 'Ρωτήστε το Concierge οτιδήποτε', + }, }, allSettingsScreen: { subscription: 'Συνδρομή', diff --git a/src/languages/en.ts b/src/languages/en.ts index 2884f2968382..173c877d6d0f 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -969,6 +969,13 @@ const translations = { }, homePage: { forYou: 'For you', + conciergePrompt: { + goodMorning: ({name}: {name?: string}) => (name ? `Good morning, ${name}.` : 'Good morning.'), + goodAfternoon: ({name}: {name?: string}) => (name ? `Good afternoon, ${name}.` : 'Good afternoon.'), + goodEvening: ({name}: {name?: string}) => (name ? `Good evening, ${name}.` : 'Good evening.'), + inputPlaceholder: 'Ask Concierge to analyze your expenses or get support', + inputPlaceholderMobile: 'Ask Concierge anything', + }, timeSensitiveSection: { title: 'Time sensitive', ctaFix: 'Fix', diff --git a/src/languages/es.ts b/src/languages/es.ts index db2d74a3dbf0..f225ff30eac6 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -1084,6 +1084,13 @@ const translations: TranslationDeepObject = { emptyStateMessage: 'Crea uno o arrastra un recibo aquí', }, insightsSection: {chartUnavailable: 'Gráfico no disponible', notEnoughData: 'Todavía no tenemos suficientes datos para completar este gráfico'}, + conciergePrompt: { + goodMorning: ({name}: {name?: string}) => (name ? `Buenos días, ${name}.` : 'Buenos días.'), + goodAfternoon: ({name}: {name?: string}) => (name ? `Buenas tardes, ${name}.` : 'Buenas tardes.'), + goodEvening: ({name}: {name?: string}) => (name ? `Buenas noches, ${name}.` : 'Buenas noches.'), + inputPlaceholder: 'Pídele a Concierge que analice tus gastos o que te ayude', + inputPlaceholderMobile: 'Pregunta a Concierge cualquier cosa', + }, }, allSettingsScreen: { subscription: 'Suscripcion', diff --git a/src/languages/fr.ts b/src/languages/fr.ts index 0a7e3518a21d..96577496f78a 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -1092,6 +1092,13 @@ const translations: TranslationDeepObject = { emptyStateMessage: 'Créez-en un ou faites glisser un reçu ici', }, insightsSection: {chartUnavailable: 'Graphique indisponible', notEnoughData: 'Nous n’avons pas encore suffisamment de données pour remplir ce graphique'}, + conciergePrompt: { + goodMorning: ({name}: {name?: string}) => (name ? `Bonjour, ${name}.` : 'Bonjour.'), + goodAfternoon: ({name}: {name?: string}) => (name ? `Bonjour, ${name}.` : 'Bon après-midi.'), + goodEvening: ({name}: {name?: string}) => (name ? `Bonsoir, ${name}.` : 'Bonsoir.'), + inputPlaceholder: 'Demander à Concierge d’analyser vos dépenses ou d’obtenir de l’aide', + inputPlaceholderMobile: 'Demander n’importe quoi à Concierge', + }, }, allSettingsScreen: { subscription: 'Abonnement', diff --git a/src/languages/it.ts b/src/languages/it.ts index b6ed31040144..0aa07ecd6657 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -1090,6 +1090,13 @@ const translations: TranslationDeepObject = { emptyStateMessage: 'Creane una o trascina qui una ricevuta', }, insightsSection: {chartUnavailable: 'Grafico non disponibile', notEnoughData: 'Non abbiamo ancora abbastanza dati per compilare questo grafico'}, + conciergePrompt: { + goodMorning: ({name}: {name?: string}) => (name ? `Buongiorno, ${name}.` : 'Buongiorno.'), + goodAfternoon: ({name}: {name?: string}) => (name ? `Buon pomeriggio, ${name}.` : 'Buon pomeriggio.'), + goodEvening: ({name}: {name?: string}) => (name ? `Buonasera, ${name}.` : 'Buona sera.'), + inputPlaceholder: 'Chiedi a Concierge di analizzare le tue spese o chiedi supporto', + inputPlaceholderMobile: 'Chiedi qualsiasi cosa a Concierge', + }, }, allSettingsScreen: { subscription: 'Abbonamento', diff --git a/src/languages/ja.ts b/src/languages/ja.ts index ee2e82bc9ddd..a106dd264f90 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -1073,6 +1073,13 @@ const translations: TranslationDeepObject = { emptyStateMessage: '新規作成するか、レシートをここにドラッグしてください', }, insightsSection: {chartUnavailable: 'グラフを表示できません', notEnoughData: 'このチャートを表示するためのデータがまだ十分にありません'}, + conciergePrompt: { + goodMorning: ({name}: {name?: string}) => (name ? `${name}さん、おはようございます。` : 'おはようございます。'), + goodAfternoon: ({name}: {name?: string}) => (name ? `${name}さん、こんにちは。` : 'こんにちは。'), + goodEvening: ({name}: {name?: string}) => (name ? `${name}さん、こんばんは。` : 'こんばんは。'), + inputPlaceholder: 'Concierge に経費の分析を依頼するか、サポートを受けます', + inputPlaceholderMobile: 'Concierge に何でも聞いてください', + }, }, allSettingsScreen: { subscription: 'サブスクリプション', diff --git a/src/languages/nl.ts b/src/languages/nl.ts index 24defae3e317..08981e74620d 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -1088,6 +1088,13 @@ const translations: TranslationDeepObject = { emptyStateMessage: 'Maak er een aan of sleep hier een bonnetje naartoe', }, insightsSection: {chartUnavailable: 'Diagram niet beschikbaar', notEnoughData: 'We hebben nog niet genoeg gegevens om deze grafiek te vullen'}, + conciergePrompt: { + goodMorning: ({name}: {name?: string}) => (name ? `Goedemorgen, ${name}.` : 'Goedemorgen.'), + goodAfternoon: ({name}: {name?: string}) => (name ? `Goedemiddag, ${name}.` : 'Goedemiddag.'), + goodEvening: ({name}: {name?: string}) => (name ? `Goedenavond, ${name}.` : 'Goedenavond.'), + inputPlaceholder: 'Vraag Concierge om je uitgaven te analyseren of om hulp te krijgen', + inputPlaceholderMobile: 'Stel Concierge alles gerust een vraag', + }, }, allSettingsScreen: { subscription: 'Abonnement', diff --git a/src/languages/pl.ts b/src/languages/pl.ts index d260cc752367..020b8113554b 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -1086,6 +1086,13 @@ const translations: TranslationDeepObject = { emptyStateMessage: 'Utwórz jeden lub przeciągnij tu paragon', }, insightsSection: {chartUnavailable: 'Wykres niedostępny', notEnoughData: 'Nie mamy jeszcze wystarczającej ilości danych, żeby wypełnić ten wykres'}, + conciergePrompt: { + goodMorning: ({name}: {name?: string}) => (name ? `Dzień dobry, ${name}.` : 'Dzień dobry.'), + goodAfternoon: ({name}: {name?: string}) => (name ? `Dzień dobry, ${name}.` : 'Dzień dobry.'), + goodEvening: ({name}: {name?: string}) => (name ? `Dobry wieczór, ${name}.` : 'Dobry wieczór.'), + inputPlaceholder: 'Poproś Concierge o przeanalizowanie swoich wydatków lub uzyskaj pomoc', + inputPlaceholderMobile: 'Zapytaj Concierge o cokolwiek', + }, }, allSettingsScreen: { subscription: 'Subskrypcja', diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index 771050f8bcad..acfa2236b22b 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -1088,6 +1088,13 @@ const translations: TranslationDeepObject = { emptyStateMessage: 'Crie um ou arraste um recibo aqui', }, insightsSection: {chartUnavailable: 'Gráfico indisponível', notEnoughData: 'Ainda não temos dados suficientes para preencher este gráfico'}, + conciergePrompt: { + goodMorning: ({name}: {name?: string}) => (name ? `Bom dia, ${name}.` : 'Bom dia.'), + goodAfternoon: ({name}: {name?: string}) => (name ? `Boa tarde, ${name}.` : 'Boa tarde.'), + goodEvening: ({name}: {name?: string}) => (name ? `Boa noite, ${name}.` : 'Boa noite.'), + inputPlaceholder: 'Peça ao Concierge para analisar suas despesas ou obter suporte', + inputPlaceholderMobile: 'Pergunte qualquer coisa ao Concierge', + }, }, allSettingsScreen: { subscription: 'Assinatura', diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index e29622ce0acf..c8cd860e5225 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -1044,6 +1044,13 @@ const translations: TranslationDeepObject = { seeMore: ({count}: {count: number}) => `再查看 ${count} 个`, recentlyAddedSection: {title: '最近添加', viewAll: '查看所有报销费用', emptyStateTitle: '最近没有报销记录', emptyStateMessage: '创建一个或将收据拖到这里'}, insightsSection: {chartUnavailable: '图表不可用', notEnoughData: '我们目前没有足够的数据来填充此图表'}, + conciergePrompt: { + goodMorning: ({name}: {name?: string}) => (name ? `早上好,${name}。` : '早上好。'), + goodAfternoon: ({name}: {name?: string}) => (name ? `下午好,${name}。` : '下午好。'), + goodEvening: ({name}: {name?: string}) => (name ? `晚上好,${name}。` : '晚上好。'), + inputPlaceholder: '向 Concierge 请求分析你的报销或获取支持', + inputPlaceholderMobile: '向 Concierge 提问任何问题', + }, }, allSettingsScreen: { subscription: '订阅', diff --git a/src/libs/DateUtils.ts b/src/libs/DateUtils.ts index 1ba4ee4e460e..7b5242db9833 100644 --- a/src/libs/DateUtils.ts +++ b/src/libs/DateUtils.ts @@ -260,6 +260,30 @@ function formatToLongDateWithWeekday(datetime: string | Date, dateFnsLocale: Dat return format(new Date(datetime), CONST.DATE.LONG_DATE_FORMAT_WITH_WEEKDAY, {locale: dateFnsLocale}); } +/** + * Format date to a long date format with weekday but without the year + * + * @returns Sunday, July 9 + */ +function formatToLongDateWithWeekdayWithoutYear(datetime: string | Date, dateFnsLocale: DateFnsLocale | undefined): string { + return format(new Date(datetime), CONST.DATE.LONG_DATE_FORMAT_WITH_WEEKDAY_WITHOUT_YEAR, {locale: dateFnsLocale}); +} + +/** + * Get the time-of-day greeting key based on the hour of the given (already timezone-adjusted) date. + * Ranges: morning 4am to 12pm, afternoon 12pm to 5pm, evening 5pm to 4am. + */ +function getTimeOfDayGreetingKey(date: Date): 'goodMorning' | 'goodAfternoon' | 'goodEvening' { + const hour = date.getHours(); + if (hour >= 4 && hour < 12) { + return 'goodMorning'; + } + if (hour >= 12 && hour < 17) { + return 'goodAfternoon'; + } + return 'goodEvening'; +} + /** * Format date to a weekday format * @@ -1182,6 +1206,8 @@ const DateUtils = { isDate, formatToDayOfWeek, formatToLongDateWithWeekday, + formatToLongDateWithWeekdayWithoutYear, + getTimeOfDayGreetingKey, formatToLocalTime, formatToReadableString, getZoneAbbreviation, diff --git a/src/pages/home/ForYouSection/ConciergePromptBox.tsx b/src/pages/home/ForYouSection/ConciergePromptBox.tsx new file mode 100644 index 000000000000..db90c9359f5c --- /dev/null +++ b/src/pages/home/ForYouSection/ConciergePromptBox.tsx @@ -0,0 +1,214 @@ +import AttachmentPicker from '@components/AttachmentPicker'; +import Icon from '@components/Icon'; +import PopoverMenu from '@components/PopoverMenu'; +import {PressableWithFeedback} from '@components/Pressable'; +import RNTextInput from '@components/RNTextInput'; +import useAskConcierge from '@components/Search/SearchRouter/useAskConcierge'; +import Text from '@components/Text'; + +import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; +import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; +import useLocalize from '@hooks/useLocalize'; +import usePopoverPosition from '@hooks/usePopoverPosition'; +import useResponsiveLayout from '@hooks/useResponsiveLayout'; +import useTheme from '@hooks/useTheme'; +import useThemeStyles from '@hooks/useThemeStyles'; + +import {isSafari} from '@libs/Browser'; +import DateUtils from '@libs/DateUtils'; + +import variables from '@styles/variables'; + +import {close} from '@userActions/Modal'; + +import CONST from '@src/CONST'; +import type {AnchorPosition} from '@src/styles'; +import type {FileObject} from '@src/types/utils/Attachment'; + +import React, {useEffect, useRef, useState} from 'react'; +import {View} from 'react-native'; + +import useConciergeAttachmentPicker from './useConciergeAttachmentPicker'; + +// Max input height (~5 lines) before the input starts scrolling internally. +const MAX_INPUT_HEIGHT = variables.componentSizeNormal * 3; + +function ConciergePromptBox() { + const styles = useThemeStyles(); + const theme = useTheme(); + const {translate, getLocalDateFromDatetime, dateFnsLocale} = useLocalize(); + const {shouldUseNarrowLayout} = useResponsiveLayout(); + const {firstName} = useCurrentUserPersonalDetails(); + const {askConcierge, askConciergeWithAttachment, shouldShowAskConcierge, conciergeTargetReportID} = useAskConcierge({forceConcierge: true}); + const icons = useMemoizedLazyExpensifyIcons(['Plus', 'Send', 'Paperclip']); + const {calculatePopoverPosition} = usePopoverPosition(); + const [value, setValue] = useState(''); + // The RNTextInput can't auto-grow on its own, so we measure a hidden mirror of its content + // (grows and shrinks correctly across web/native) and drive the input height from it. + const [inputWidth, setInputWidth] = useState(0); + const [contentHeight, setContentHeight] = useState(variables.lineHeightXLarge); + const [isFocused, setIsFocused] = useState(false); + const [isMenuVisible, setIsMenuVisible] = useState(false); + const [popoverAnchorPosition, setPopoverAnchorPosition] = useState(null); + const actionButtonRef = useRef(null); + + const sendAttachment = (attachments: FileObject | FileObject[]) => { + askConciergeWithAttachment(attachments, value); + setValue(''); + }; + const {pickAttachments, PDFValidationComponent} = useConciergeAttachmentPicker(conciergeTargetReportID, sendAttachment); + + // Anchor the "+" popover above the button, mirroring the composer's attachment menu. + useEffect(() => { + if (!actionButtonRef.current || !isMenuVisible) { + return; + } + calculatePopoverPosition(actionButtonRef, { + horizontal: CONST.MODAL.ANCHOR_ORIGIN_HORIZONTAL.LEFT, + vertical: CONST.MODAL.ANCHOR_ORIGIN_VERTICAL.BOTTOM, + }).then((position) => { + setPopoverAnchorPosition({...position, vertical: position.vertical - CONST.MODAL.POPOVER_MENU_PADDING}); + }); + }, [isMenuVisible, calculatePopoverPosition]); + + // Current moment in the user's timezone (resolved by the localization provider). + const localNow = getLocalDateFromDatetime(); + const dateLabel = DateUtils.formatToLongDateWithWeekdayWithoutYear(localNow, dateFnsLocale); + const greeting = translate(`homePage.conciergePrompt.${DateUtils.getTimeOfDayGreetingKey(localNow)}`, {name: firstName}); + const placeholder = translate(shouldUseNarrowLayout ? 'homePage.conciergePrompt.inputPlaceholderMobile' : 'homePage.conciergePrompt.inputPlaceholder'); + const canSubmit = shouldShowAskConcierge && value.trim().length > 0; + + // Grow the input to fit its content (measured mirror + vertical padding), clamped between a single + // line and a max height (~5 lines) after which the input scrolls internally. + const inputHeight = Math.min(MAX_INPUT_HEIGHT, Math.max(variables.componentSizeNormal, contentHeight + variables.componentSizeNormal - variables.lineHeightXLarge)); + + const submit = () => { + if (!canSubmit) { + return; + } + askConcierge(value); + setValue(''); + }; + + return ( + + + {dateLabel} + {greeting} + + + + + {({openPicker}) => { + const triggerAttachmentPicker = () => openPicker({onPicked: pickAttachments}); + return ( + <> + { + e?.preventDefault(); + actionButtonRef.current?.blur(); + setIsMenuVisible((prev) => !prev); + }} + style={styles.conciergePromptBoxAddButton} + > + + + setIsMenuVisible(false)} + onItemSelected={() => { + setIsMenuVisible(false); + + // On Safari the file picker must be opened from within the user-initiated + // event handler, so it can't wait for the popover to finish closing. + if (isSafari()) { + triggerAttachmentPicker(); + return; + } + close(triggerAttachmentPicker); + }} + anchorPosition={popoverAnchorPosition ?? {horizontal: 0, vertical: 0}} + anchorAlignment={{ + horizontal: CONST.MODAL.ANCHOR_ORIGIN_HORIZONTAL.LEFT, + vertical: CONST.MODAL.ANCHOR_ORIGIN_VERTICAL.BOTTOM, + }} + menuItems={[ + { + icon: icons.Paperclip, + text: translate('reportActionCompose.addAttachment'), + shouldCallAfterModalHide: shouldUseNarrowLayout, + }, + ]} + anchorRef={actionButtonRef} + /> + + ); + }} + + + + + setInputWidth(e.nativeEvent.layout.width)} + onFocus={() => setIsFocused(true)} + onBlur={() => setIsFocused(false)} + multiline + placeholder={placeholder} + placeholderTextColor={theme.placeholderText} + onSubmitEditing={submit} + submitBehavior="submit" + returnKeyType="send" + accessibilityLabel={placeholder} + /> + {inputWidth > 0 && ( + setContentHeight(e.nativeEvent.layout.height)} + accessible={false} + aria-hidden + > + {/* Trailing zero-width space so a value ending in a newline still measures the extra line. */} + {value ? `${value}${value.endsWith('\n') ? '\u200B' : ''}` : placeholder} + + )} + + + + + + {PDFValidationComponent} + + ); +} + +ConciergePromptBox.displayName = 'ConciergePromptBox'; + +export default ConciergePromptBox; diff --git a/src/pages/home/ForYouSection/index.tsx b/src/pages/home/ForYouSection/index.tsx index d5c79257a972..4e4bf45a733b 100644 --- a/src/pages/home/ForYouSection/index.tsx +++ b/src/pages/home/ForYouSection/index.tsx @@ -1,4 +1,5 @@ import BaseWidgetItem from '@components/BaseWidgetItem'; +import Text from '@components/Text'; import WidgetContainer from '@components/WidgetContainer'; import {useAppLoadSkeletonState} from '@hooks/useInFlightRequests'; @@ -14,6 +15,9 @@ import {setHasSeenForYouTodo} from '@libs/actions/Todos'; import Navigation from '@libs/Navigation/Navigation'; import {buildQueryStringFromFilterFormValues} from '@libs/SearchQueryUtils'; +import TimeSensitiveGroup from '@pages/home/TimeSensitiveSection/TimeSensitiveGroup'; +import useTimeSensitiveItems from '@pages/home/TimeSensitiveSection/useTimeSensitiveItems'; + import colors from '@styles/theme/colors'; import CONST from '@src/CONST'; @@ -27,6 +31,7 @@ import {useIsFocused} from '@react-navigation/native'; import React, {useCallback, useEffect, useMemo} from 'react'; import {View} from 'react-native'; +import ConciergePromptBox from './ConciergePromptBox'; import EmptyState from './EmptyState'; import ForYouSkeleton from './ForYouSkeleton'; import shouldHideForYouSection from './shouldHideForYouSection'; @@ -51,6 +56,9 @@ function ForYouSection() { const isNewDotOnboardedUser = !isEmptyObject(onboarding); const [hasSeenForYouTodo = false] = useOnyx(ONYXKEYS.NVP_HAS_SEEN_FOR_YOU_TODO); const {count: flaggedExpensesCount, reviewExpenses} = useReviewFlaggedExpenses(); + // "Time sensitive" now lives inside this card as a group above the "For you" todos (chat input stays on top). + const timeSensitiveItems = useTimeSensitiveItems(); + const hasTimeSensitiveContent = timeSensitiveItems.length > 0; const icons = useMemoizedLazyExpensifyIcons(['ReceiptSearch', 'MoneyBag', 'Send', 'ThumbsUp', 'Export']); @@ -193,22 +201,36 @@ function ForYouSection() { return hasAnyTodos ? renderTodoItems() : ; }; - if ( - shouldHideForYouSection({ - isInitialLoad, - hasAnyTodos, - hasSeenTodo: hasSeenForYouTodo, - firstDayFreeTrial, - cutoffDate: CONST.HOME.FOR_YOU_NEW_USER_CUTOFF_DATE, - isOnboardingCompleted, - isOnboardingStatusKnown, - isNewDotOnboardedUser, - }) - ) { + const hideForYou = shouldHideForYouSection({ + isInitialLoad, + hasAnyTodos, + hasSeenTodo: hasSeenForYouTodo, + firstDayFreeTrial, + cutoffDate: CONST.HOME.FOR_YOU_NEW_USER_CUTOFF_DATE, + isOnboardingCompleted, + isOnboardingStatusKnown, + isNewDotOnboardedUser, + }); + + // Keep the card (and its Concierge input) visible when the "For you" part is hidden but there's time-sensitive + // content, so those alerts aren't lost for users who wouldn't otherwise see the "For you" section. + if (hideForYou && !hasTimeSensitiveContent) { return null; } - return {renderContent()}; + return ( + }> + + {!hideForYou && ( + <> + + {translate('homePage.forYou')} + + {renderContent()} + + )} + + ); } export default ForYouSection; diff --git a/src/pages/home/ForYouSection/useConciergeAttachmentPicker.ts b/src/pages/home/ForYouSection/useConciergeAttachmentPicker.ts new file mode 100644 index 000000000000..3d873414a5b1 --- /dev/null +++ b/src/pages/home/ForYouSection/useConciergeAttachmentPicker.ts @@ -0,0 +1,51 @@ +import useFilesValidation from '@hooks/useFilesValidation'; +import useLocalize from '@hooks/useLocalize'; + +import {cleanFileObject, cleanFileObjectName} from '@libs/fileDownload/FileUtils'; + +import Navigation from '@navigation/Navigation'; + +import AttachmentModalContext from '@pages/media/AttachmentModalScreen/AttachmentModalContext'; + +import ROUTES from '@src/ROUTES'; +import type SCREENS from '@src/SCREENS'; +import type {FileObject} from '@src/types/utils/Attachment'; + +import {useContext} from 'react'; + +/** + * Lets the Concierge prompt box pick file(s) and open the shared attachment preview modal. + * On confirm the modal invokes `onConfirm`, which is where the caller actually sends the attachment to Concierge. + */ +function useConciergeAttachmentPicker(reportID: string | undefined, onConfirm: (files: FileObject | FileObject[]) => void) { + const {translate} = useLocalize(); + const reportAttachmentsContext = useContext(AttachmentModalContext); + + const onFilesValidated = (files: FileObject[]) => { + if (files.length === 0 || !reportID) { + return; + } + + reportAttachmentsContext.setCurrentAttachment({ + reportID, + file: files, + headerTitle: translate('reportActionCompose.sendAttachment'), + onConfirm, + }); + Navigation.navigate(ROUTES.REPORT_ADD_ATTACHMENT.getRoute(reportID)); + }; + + const {validateFiles, PDFValidationComponent} = useFilesValidation(onFilesValidated); + + const pickAttachments = (files: FileObject[]) => { + const fileObjects = files.map((item) => cleanFileObjectName(cleanFileObject(item))); + if (!fileObjects.length) { + return; + } + validateFiles(fileObjects, undefined, {isValidatingReceipts: false}); + }; + + return {pickAttachments, PDFValidationComponent}; +} + +export default useConciergeAttachmentPicker; diff --git a/src/pages/home/HomePage.tsx b/src/pages/home/HomePage.tsx index a92fbee762af..6fa841861568 100644 --- a/src/pages/home/HomePage.tsx +++ b/src/pages/home/HomePage.tsx @@ -26,7 +26,6 @@ import FreeTrialSection from './FreeTrialSection'; import GettingStartedSection from './GettingStartedSection'; import InsightsSection from './InsightsSection'; import RecentlyAddedSection from './RecentlyAddedSection'; -import TimeSensitiveSection from './TimeSensitiveSection'; import UpcomingTravelSection from './UpcomingTravelSection'; import YourSpendSection from './YourSpendSection'; @@ -69,7 +68,6 @@ function HomePage() { {shouldUseNarrowLayout ? ( <> - @@ -85,7 +83,6 @@ function HomePage() { testID="homePageLeftColumn" style={styles.homePageLeftColumn} > - diff --git a/src/pages/home/TimeSensitiveSection/TimeSensitiveGroup.tsx b/src/pages/home/TimeSensitiveSection/TimeSensitiveGroup.tsx new file mode 100644 index 000000000000..4ec4f28f503e --- /dev/null +++ b/src/pages/home/TimeSensitiveSection/TimeSensitiveGroup.tsx @@ -0,0 +1,64 @@ +import Text from '@components/Text'; + +import useLocalize from '@hooks/useLocalize'; +import useResponsiveLayout from '@hooks/useResponsiveLayout'; +import useTheme from '@hooks/useTheme'; +import useThemeStyles from '@hooks/useThemeStyles'; + +import HomeSectionExpandToggle from '@pages/home/HomeSectionExpandToggle'; + +import CONST from '@src/CONST'; + +import {useFocusEffect} from '@react-navigation/native'; +import React, {useCallback, useState} from 'react'; +import {View} from 'react-native'; + +type TimeSensitiveGroupProps = { + /** The prebuilt time-sensitive item rows (from useTimeSensitiveItems). */ + items: React.ReactNode[]; +}; + +/** + * Renders the "Time sensitive" heading and item rows as a group inside another card (the Home "For you" card). + * Returns null when there are no items so the group leaves no trace. It's the card owner's job to keep the card + * visible whenever this group has content. + */ +function TimeSensitiveGroup({items}: TimeSensitiveGroupProps) { + const styles = useThemeStyles(); + const theme = useTheme(); + const {translate} = useLocalize(); + const {shouldUseNarrowLayout} = useResponsiveLayout(); + const [isExpanded, setIsExpanded] = useState(false); + + // Collapse again whenever the user leaves and returns to Home. + useFocusEffect(useCallback(() => () => setIsExpanded(false), [])); + + if (items.length === 0) { + return null; + } + + const hiddenCount = Math.max(0, items.length - CONST.HOME.SECTION_VISIBLE_LIMIT); + const visibleItems = isExpanded ? items : items.slice(0, CONST.HOME.SECTION_VISIBLE_LIMIT); + + return ( + <> + + {translate('homePage.timeSensitiveSection.title')} + + + {visibleItems} + {hiddenCount > 0 && ( + setIsExpanded((prev) => !prev)} + collapsedLabel={translate('homePage.seeMore', {count: hiddenCount})} + /> + )} + + + ); +} + +TimeSensitiveGroup.displayName = 'TimeSensitiveGroup'; + +export default TimeSensitiveGroup; diff --git a/src/pages/home/TimeSensitiveSection/index.tsx b/src/pages/home/TimeSensitiveSection/useTimeSensitiveItems.tsx similarity index 79% rename from src/pages/home/TimeSensitiveSection/index.tsx rename to src/pages/home/TimeSensitiveSection/useTimeSensitiveItems.tsx index aef9df798b9b..339389db0b33 100644 --- a/src/pages/home/TimeSensitiveSection/index.tsx +++ b/src/pages/home/TimeSensitiveSection/useTimeSensitiveItems.tsx @@ -1,32 +1,24 @@ -import WidgetContainer from '@components/WidgetContainer'; - import useCardFeedErrors from '@hooks/useCardFeedErrors'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import useIsAnonymousUser from '@hooks/useIsAnonymousUser'; -import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; -import useResponsiveLayout from '@hooks/useResponsiveLayout'; -import useThemeStyles from '@hooks/useThemeStyles'; import {hasSynchronizationErrorMessage, isConnectionInProgress} from '@libs/actions/connections'; import {getConnectedHRProvider} from '@libs/HRUtils'; import {expensifyLoginsSelector, isCurrentUserValidated} from '@libs/UserUtils'; -import HomeSectionExpandToggle from '@pages/home/HomeSectionExpandToggle'; - import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type {Policy} from '@src/types/onyx'; -import type {ConnectionName, PolicyConnectionName} from '@src/types/onyx/Policy'; +import type {PolicyConnectionName} from '@src/types/onyx/Policy'; +import ObjectUtils from '@src/types/utils/ObjectUtils'; import type {OnyxCollection} from 'react-native-onyx'; -import {useFocusEffect} from '@react-navigation/native'; import {isUserValidatedSelector} from '@selectors/Account'; import {activeAdminPoliciesSelector} from '@selectors/Policy'; import {emailSelector} from '@selectors/Session'; -import React, {useCallback, useState} from 'react'; -import {View} from 'react-native'; +import React, {useCallback} from 'react'; import useBrokenDirectCompanyCardFeedsForAdmin from './hooks/useBrokenDirectCompanyCardFeedsForAdmin'; import useTimeSensitiveAddBankAccount from './hooks/useTimeSensitiveAddBankAccount'; @@ -68,19 +60,13 @@ type BrokenPersonalCardConnection = { cardID: string; }; -function TimeSensitiveSection() { - const styles = useThemeStyles(); - const {translate} = useLocalize(); - const {shouldUseNarrowLayout} = useResponsiveLayout(); +/** + * Builds the prioritized list of time-sensitive action rows for the Home page. Returns an empty array when the user + * has no time-sensitive content, so the caller can decide whether to render the "Time sensitive" group at all. + */ +function useTimeSensitiveItems(): React.ReactNode[] { const {login} = useCurrentUserPersonalDetails(); const isAnonymous = useIsAnonymousUser(); - const [isExpanded, setIsExpanded] = useState(false); - - useFocusEffect( - useCallback(() => { - return () => setIsExpanded(false); - }, []), - ); // Use custom hooks for offers and cards (Release 3) const {shouldShowAddPaymentCard} = useTimeSensitiveAddPaymentCard(); @@ -109,7 +95,7 @@ function TimeSensitiveSection() { const [loginList] = useOnyx(ONYXKEYS.LOGINS, {selector: expensifyLoginsSelector}); const [sessionEmail] = useOnyx(ONYXKEYS.SESSION, {selector: emailSelector}); const {lockedBankAccounts} = useTimeSensitiveLockedBankAccount(adminPolicies); - const {shouldShowEnterSignerInfo, pendingSignerInfo} = useTimeSensitiveSignerInfo(); + const {pendingSignerInfo} = useTimeSensitiveSignerInfo(); // Get card feed errors for company card connections (Release 4) const cardFeedErrors = useCardFeedErrors(); @@ -127,7 +113,7 @@ function TimeSensitiveSection() { const syncProgress = connectionSyncProgress?.[`${ONYXKEYS.COLLECTION.POLICY_CONNECTION_SYNC_PROGRESS}${policy.id}`]; const isSyncInProgress = isConnectionInProgress(syncProgress, policy); - for (const connectionName of Object.keys(policyConnections) as ConnectionName[]) { + for (const [connectionName] of ObjectUtils.typedEntries(policyConnections)) { if (hasSynchronizationErrorMessage(policy, connectionName, isSyncInProgress)) { const integrationName = connectionName === CONST.POLICY.CONNECTIONS.NAME.MERGE_HR @@ -154,34 +140,9 @@ function TimeSensitiveSection() { } } - const hasBrokenCompanyCards = brokenCompanyCardConnections.length > 0; - const hasBrokenPersonalCards = brokenPersonalCardConnections.length > 0; - const hasBrokenPolicyConnections = brokenPolicyConnections.length > 0; const isCurrentLoginValidated = isCurrentUserValidated(loginList, sessionEmail ?? login); const shouldShowValidateAccount = isUserValidated === false && !isAnonymous && !isCurrentLoginValidated; - // This guard must exactly match the conditions used to render each widget below. - // If a widget has additional conditions in the render (e.g. && !!discountInfo), those - // must be reflected here to avoid showing an empty "Time sensitive" section. - const hasAnyTimeSensitiveContent = - lockedBankAccounts.length > 0 || - shouldShowEnterSignerInfo || - shouldShowValidateAccount || - shouldShowFixFailedBilling || - shouldShowReviewCardFraud || - shouldShowAddPaymentCard || - shouldShowAddBankAccount || - hasBrokenCompanyCards || - hasBrokenPersonalCards || - hasBrokenPolicyConnections || - shouldShowAddShippingAddress || - shouldShowActivateCard || - shouldShowAddVirtualCardPersonalDetails; - - if (!hasAnyTimeSensitiveContent) { - return null; - } - // Priority order: // 1. Validate account // 2. Fix failed billing (existing customers with declined cards) @@ -323,23 +284,7 @@ function TimeSensitiveSection() { } } - const hiddenCount = Math.max(0, items.length - CONST.HOME.SECTION_VISIBLE_LIMIT); - const visibleItems = isExpanded ? items : items.slice(0, CONST.HOME.SECTION_VISIBLE_LIMIT); - - return ( - - - {visibleItems} - {hiddenCount > 0 && ( - setIsExpanded((prev) => !prev)} - collapsedLabel={translate('homePage.seeMore', {count: hiddenCount})} - /> - )} - - - ); + return items; } -export default TimeSensitiveSection; +export default useTimeSensitiveItems; diff --git a/src/styles/index.ts b/src/styles/index.ts index b4695e8a3617..48f304896164 100644 --- a/src/styles/index.ts +++ b/src/styles/index.ts @@ -7228,6 +7228,56 @@ const plainStyles = (theme: ThemeColors) => marginTop: shouldUseNarrowLayout ? 20 : 32, }) satisfies ViewStyle, + getConciergePromptBoxContainerStyle: (isFocused: boolean) => + ({ + borderWidth: 1, + borderColor: isFocused ? theme.borderFocus : theme.border, + borderRadius: variables.componentBorderRadiusRounded, + backgroundColor: theme.appBG, + minHeight: variables.componentSizeMedium, + }) satisfies ViewStyle, + + conciergePromptBoxAddButton: { + alignItems: 'center', + justifyContent: 'center', + height: variables.componentSizeNormal, + paddingLeft: 8, + paddingRight: 4, + }, + + conciergePromptBoxDivider: { + width: 1, + alignSelf: 'stretch', + // Cancel the container's vertical padding (pv1) so the divider spans edge-to-edge + // while the buttons and input keep their vertical breathing room. + marginVertical: -4, + backgroundColor: theme.border, + }, + + conciergePromptBoxInput: { + color: theme.text, + lineHeight: variables.lineHeightXLarge, + paddingVertical: (variables.componentSizeNormal - variables.lineHeightXLarge) / 2, + minHeight: variables.componentSizeNormal, + maxHeight: variables.componentSizeNormal * 3, + fontFamily: FontUtils.fontFamily.platform.EXP_NEUE.fontFamily, + textAlignVertical: 'top', + } satisfies TextStyle, + + // Font-only styles matching the input, used by the hidden mirror that measures content height. + conciergePromptBoxInputMeasure: { + lineHeight: variables.lineHeightXLarge, + fontFamily: FontUtils.fontFamily.platform.EXP_NEUE.fontFamily, + } satisfies TextStyle, + + conciergePromptBoxSendButton: { + alignItems: 'center', + justifyContent: 'center', + width: variables.componentSizeNormal, + height: variables.componentSizeNormal, + borderRadius: variables.componentBorderRadiusRounded, + }, + getWidgetItemIconContainerStyle: (backgroundColor: string) => ({ alignItems: 'center', diff --git a/tests/ui/ForYouSectionTest.tsx b/tests/ui/ForYouSectionTest.tsx index 56dca0c33ae7..e2c9febac479 100644 --- a/tests/ui/ForYouSectionTest.tsx +++ b/tests/ui/ForYouSectionTest.tsx @@ -57,6 +57,16 @@ jest.mock('@pages/home/ForYouSection/ForYouSkeleton', () => () => { return ReactModule.createElement('View', {testID: 'for-you-skeleton'}); }); +jest.mock('@pages/home/ForYouSection/ConciergePromptBox', () => () => { + const ReactModule = jest.requireActual('react'); + return ReactModule.createElement('View', {testID: 'concierge-prompt-box'}); +}); + +// The "Time sensitive" group is exercised in its own tests; stub it out here so these tests stay focused on "For you" +// (and so it doesn't pull in useFocusEffect, which needs a NavigationContainer this harness doesn't provide). +jest.mock('@pages/home/TimeSensitiveSection/useTimeSensitiveItems', () => jest.fn(() => [])); +jest.mock('@pages/home/TimeSensitiveSection/TimeSensitiveGroup', () => () => null); + // ForYouSection calls useIsFocused() to freeze useTodoCounts when unfocused; this test renders it outside a // NavigationContainer, so stub the focus hook (useTodoCounts is mocked, so the focus value itself is irrelevant). jest.mock('@react-navigation/native', () => { diff --git a/tests/unit/DateUtilsTest.ts b/tests/unit/DateUtilsTest.ts index 699bebcf7616..b421c6a3db72 100644 --- a/tests/unit/DateUtilsTest.ts +++ b/tests/unit/DateUtilsTest.ts @@ -725,6 +725,29 @@ describe('DateUtils', () => { }); }); + describe('getTimeOfDayGreetingKey', () => { + const atHour = (hour: number, minute = 0) => set(new Date(), {hours: hour, minutes: minute, seconds: 0, milliseconds: 0}); + + it('should return goodMorning from 4am up to noon', () => { + expect(DateUtils.getTimeOfDayGreetingKey(atHour(4))).toBe('goodMorning'); + expect(DateUtils.getTimeOfDayGreetingKey(atHour(8, 30))).toBe('goodMorning'); + expect(DateUtils.getTimeOfDayGreetingKey(atHour(11, 59))).toBe('goodMorning'); + }); + + it('should return goodAfternoon from noon up to 5pm', () => { + expect(DateUtils.getTimeOfDayGreetingKey(atHour(12))).toBe('goodAfternoon'); + expect(DateUtils.getTimeOfDayGreetingKey(atHour(14, 15))).toBe('goodAfternoon'); + expect(DateUtils.getTimeOfDayGreetingKey(atHour(16, 59))).toBe('goodAfternoon'); + }); + + it('should return goodEvening from 5pm up to 4am', () => { + expect(DateUtils.getTimeOfDayGreetingKey(atHour(17))).toBe('goodEvening'); + expect(DateUtils.getTimeOfDayGreetingKey(atHour(21))).toBe('goodEvening'); + expect(DateUtils.getTimeOfDayGreetingKey(atHour(0))).toBe('goodEvening'); + expect(DateUtils.getTimeOfDayGreetingKey(atHour(3, 59))).toBe('goodEvening'); + }); + }); + describe('time picker helpers with a non-English date-fns locale', () => { beforeEach(() => IntlStore.load(CONST.LOCALES.DE)); diff --git a/tests/unit/pages/HomePage.test.tsx b/tests/unit/pages/HomePage.test.tsx index d595d9560832..2af480adb522 100644 --- a/tests/unit/pages/HomePage.test.tsx +++ b/tests/unit/pages/HomePage.test.tsx @@ -81,7 +81,6 @@ function mockSection(name: string) { } jest.mock('@pages/home/FreeTrialSection', () => mockSection('FreeTrialSection')); -jest.mock('@pages/home/TimeSensitiveSection', () => mockSection('TimeSensitiveSection')); jest.mock('@pages/home/GettingStartedSection', () => mockSection('GettingStartedSection')); jest.mock('@pages/home/ForYouSection', () => mockSection('ForYouSection')); jest.mock('@pages/home/UpcomingTravelSection', () => mockSection('UpcomingTravelSection')); @@ -169,7 +168,6 @@ describe('HomePage', () => { expect(renderedSectionOrder()).toEqual([ 'section-FreeTrialSection', - 'section-TimeSensitiveSection', 'section-GettingStartedSection', 'section-ForYouSection', 'section-UpcomingTravelSection', diff --git a/tests/unit/pages/home/TimeSensitiveSection/AddBankAccountTest.tsx b/tests/unit/pages/home/TimeSensitiveSection/AddBankAccountTest.tsx index 76ec13efd894..2b6c078040ea 100644 --- a/tests/unit/pages/home/TimeSensitiveSection/AddBankAccountTest.tsx +++ b/tests/unit/pages/home/TimeSensitiveSection/AddBankAccountTest.tsx @@ -3,9 +3,10 @@ import {fireEvent, render, screen} from '@testing-library/react-native'; import OnyxListItemProvider from '@src/components/OnyxListItemProvider'; import {openPersonalBankAccountSetupView} from '@src/libs/actions/BankAccounts'; import ONYXKEYS from '@src/ONYXKEYS'; -import TimeSensitiveSection from '@src/pages/home/TimeSensitiveSection'; import useTimeSensitiveAddBankAccount from '@src/pages/home/TimeSensitiveSection/hooks/useTimeSensitiveAddBankAccount'; import useTimeSensitiveAddPaymentCard from '@src/pages/home/TimeSensitiveSection/hooks/useTimeSensitiveAddPaymentCard'; +import TimeSensitiveGroup from '@src/pages/home/TimeSensitiveSection/TimeSensitiveGroup'; +import useTimeSensitiveItems from '@src/pages/home/TimeSensitiveSection/useTimeSensitiveItems'; import type * as NativeNavigation from '@react-navigation/native'; @@ -67,6 +68,11 @@ jest.mock('@hooks/useCurrentUserPersonalDetails', () => jest.fn(() => ({login: ' jest.mock('@hooks/useResponsiveLayout', () => jest.fn(() => ({shouldUseNarrowLayout: false}))); +// Renders the "Time sensitive" group the way the Home "For you" card now does (hook + presentational group). +function TimeSensitiveSection() { + return ; +} + const renderTimeSensitiveSection = () => render( diff --git a/tests/unit/pages/home/TimeSensitiveSection/UnlockBankAccountTest.tsx b/tests/unit/pages/home/TimeSensitiveSection/UnlockBankAccountTest.tsx index 092b40ad7662..8d08765bfccc 100644 --- a/tests/unit/pages/home/TimeSensitiveSection/UnlockBankAccountTest.tsx +++ b/tests/unit/pages/home/TimeSensitiveSection/UnlockBankAccountTest.tsx @@ -6,7 +6,8 @@ import {navigateToConciergeChat} from '@libs/actions/Report'; import OnyxListItemProvider from '@src/components/OnyxListItemProvider'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import TimeSensitiveSection from '@src/pages/home/TimeSensitiveSection'; +import TimeSensitiveGroup from '@src/pages/home/TimeSensitiveSection/TimeSensitiveGroup'; +import useTimeSensitiveItems from '@src/pages/home/TimeSensitiveSection/useTimeSensitiveItems'; import type * as NativeNavigation from '@react-navigation/native'; @@ -70,6 +71,11 @@ const POLICY_ID = 'policy_1'; const POLICY_NAME = 'My Workspace'; const CONCIERGE_REPORT_ID = 'concierge_report_1'; +// Renders the "Time sensitive" group the way the Home "For you" card now does (hook + presentational group). +function TimeSensitiveSection() { + return ; +} + const renderTimeSensitiveSection = () => render( diff --git a/tests/unit/pages/home/TimeSensitiveSection/ValidateAccountTest.tsx b/tests/unit/pages/home/TimeSensitiveSection/ValidateAccountTest.tsx index 1eb782060cb8..dc984f18481d 100644 --- a/tests/unit/pages/home/TimeSensitiveSection/ValidateAccountTest.tsx +++ b/tests/unit/pages/home/TimeSensitiveSection/ValidateAccountTest.tsx @@ -3,8 +3,9 @@ import {render, screen} from '@testing-library/react-native'; import OnyxListItemProvider from '@src/components/OnyxListItemProvider'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import TimeSensitiveSection from '@src/pages/home/TimeSensitiveSection'; import useTimeSensitiveAddPaymentCard from '@src/pages/home/TimeSensitiveSection/hooks/useTimeSensitiveAddPaymentCard'; +import TimeSensitiveGroup from '@src/pages/home/TimeSensitiveSection/TimeSensitiveGroup'; +import useTimeSensitiveItems from '@src/pages/home/TimeSensitiveSection/useTimeSensitiveItems'; import type * as NativeNavigation from '@react-navigation/native'; @@ -61,6 +62,11 @@ jest.mock('@hooks/useCurrentUserPersonalDetails', () => jest.fn(() => ({login: ' jest.mock('@hooks/useResponsiveLayout', () => jest.fn(() => ({shouldUseNarrowLayout: false}))); +// Renders the "Time sensitive" group the way the Home "For you" card now does (hook + presentational group). +function TimeSensitiveSection() { + return ; +} + const renderTimeSensitiveSection = () => render(