From 3c95faa92ba2f2b9cf0bd92623858df89e904de0 Mon Sep 17 00:00:00 2001 From: "Kevin Brian Bader (via MelvinBot)" Date: Mon, 28 Sep 2026 22:06:50 +0000 Subject: [PATCH 01/12] Add a clear after date to vacation delegates and move them to the Profile page Co-authored-by: Kevin Brian Bader --- .../settings/Delegate-when-out-of-office.md | 22 +- src/CONST/index.ts | 1 + src/ONYXKEYS.ts | 3 + src/ROUTES.ts | 10 +- src/SCREENS.ts | 4 +- src/components/VacationDelegateForm.tsx | 129 ++++++ src/components/VacationDelegateMenuItem.tsx | 15 +- src/languages/de.ts | 4 + src/languages/el.ts | 4 + src/languages/en.ts | 4 + src/languages/es.ts | 4 + src/languages/fr.ts | 4 + src/languages/it.ts | 4 + src/languages/ja.ts | 4 + src/languages/nl.ts | 4 + src/languages/pl.ts | 4 + src/languages/pt-BR.ts | 4 + src/languages/zh-hans.ts | 4 + .../parameters/SetVacationDelegateParams.ts | 3 + .../ModalStackNavigators/index.tsx | 6 +- .../linkingConfig/RELATIONS/DOMAIN_TO_RHP.ts | 1 + .../RELATIONS/SETTINGS_TO_RHP.ts | 1 + src/libs/Navigation/linkingConfig/config.ts | 7 + src/libs/Navigation/types.ts | 5 + src/libs/VacationDelegateUtils.ts | 54 +++ src/libs/actions/Domain.ts | 24 +- src/libs/actions/VacationDelegate.ts | 26 +- .../Members/DomainMemberDetailsPage.tsx | 14 +- .../DomainMemberVacationDelegateFormPage.tsx | 87 ++++ .../DomainMemberVacationDelegatePage.tsx | 30 +- .../Profile/CustomStatus/StatusPage.tsx | 27 -- .../CustomStatus/VacationDelegateFormPage.tsx | 123 ++++++ .../index.tsx | 15 +- .../CustomStatus/VacationDelegatePage.tsx | 84 +--- src/pages/settings/Profile/ProfilePage.tsx | 31 +- src/types/form/VacationDelegateForm.ts | 24 ++ src/types/form/index.ts | 1 + src/types/onyx/VacationDelegate.ts | 9 + tests/actions/DomainTest.ts | 48 ++- tests/actions/VacationDelegateTest.ts | 48 +++ tests/ui/ProfilePageTest.tsx | 37 ++ tests/ui/VacationDelegateFormPageTest.tsx | 386 ++++++++++++++++++ ...ationDelegateMissingWorkspacesPageTest.tsx | 24 ++ tests/ui/VacationDelegatePageTest.tsx | 251 +----------- .../VacationDelegateMenuItemTest.tsx | 33 ++ tests/unit/libs/VacationDelegateUtilsTest.ts | 58 +++ 46 files changed, 1304 insertions(+), 381 deletions(-) create mode 100644 src/components/VacationDelegateForm.tsx create mode 100644 src/libs/VacationDelegateUtils.ts create mode 100644 src/pages/domain/Members/DomainMemberVacationDelegateFormPage.tsx create mode 100644 src/pages/settings/Profile/CustomStatus/VacationDelegateFormPage.tsx create mode 100644 src/types/form/VacationDelegateForm.ts create mode 100644 tests/ui/VacationDelegateFormPageTest.tsx create mode 100644 tests/unit/libs/VacationDelegateUtilsTest.ts diff --git a/docs/articles/new-expensify/settings/Delegate-when-out-of-office.md b/docs/articles/new-expensify/settings/Delegate-when-out-of-office.md index dcc4212e3991..1caaf08f33f7 100644 --- a/docs/articles/new-expensify/settings/Delegate-when-out-of-office.md +++ b/docs/articles/new-expensify/settings/Delegate-when-out-of-office.md @@ -2,14 +2,14 @@ title: Delegate When Out of Office description: Set a vacation delegate to approve reports on your behalf while you're away. keywords: [New Expensify, delegate approvals, out-of-office approver, vacation, out of office, approval delegation, temporary delegate, vacation delegate, copilot vs delegate, copilot, shared access, proxy, behalf of] -internalScope: Audience is all members. Covers assigning and removing your own vacation delegate from your profile status. Does not cover Copilot access or Expensify Classic delegates. +internalScope: Audience is all members. Covers assigning, scheduling the end of, and removing your own vacation delegate from your profile. Does not cover Copilot access or Expensify Classic delegates. --- # Delegate When Out of Office If you're out of the office or on vacation, you can assign a vacation delegate to handle your approvals while you're away. -A vacation delegate can approve reports on your behalf. Once assigned, any reports sent to you for approval will be automatically redirected to your delegate. When you're back, just remove the delegate to resume handling your own reports. +A vacation delegate can approve reports on your behalf. Once assigned, any reports sent to you for approval will be automatically redirected to your delegate. Pick a **Clear after** date and the delegate is removed automatically at the end of that day, so reports come back to you when you return. All delegate actions are tracked in the report history for full visibility. @@ -17,19 +17,19 @@ All delegate actions are tracked in the report history for full visibility. ## How to assign a vacation delegate -1. Go to **Account > Profile > Status**. -2. Under **Vacation delegate**, enter the email address or phone number of your delegate. -3. Click **Save**. +1. Go to **Account > Profile > Vacation delegate**. +2. Select the member who should approve on your behalf. +3. Optional (recommended): Under **Clear after**, pick the last day the delegate should cover. +4. Click **Save**. -Once set, any reports or chats that would normally come to you will be redirected to your delegate. You can view every action they take in the report's history and comments. +Once set, any reports or chats that would normally come to you will be redirected to your delegate. You can view every action they take in the report's history and comments. The delegate and the date it clears are shown under **Vacation delegate** on your profile. ## How to remove a vacation delegate -When you're ready to take back control of your reports: +If you set a **Clear after** date, the delegate is removed automatically. To remove it sooner: -1. Go to **Account > Profile > Status**. -2. Under **Vacation delegate**, clear the delegate's email or phone number. -3. Click **Save**. +1. Go to **Account > Profile > Vacation delegate**. +2. Click **Remove vacation delegate**. --- @@ -49,7 +49,7 @@ Your delegate can assign their own vacation delegate too. This ensures approvals ## If I clear my status, does that remove my vacation delegate? -No. Clearing your status won't affect your delegate. You'll need to manually remove them when you're back. +No. Clearing your status won't affect your delegate. Set a **Clear after** date on your vacation delegate, or remove them when you're back. ## What if my delegate isn’t in the same workspace? diff --git a/src/CONST/index.ts b/src/CONST/index.ts index b5abc0f4084b..845a4b7e6d24 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -9957,6 +9957,7 @@ const CONST = { DISPLAY_NAME: 'SettingsProfile-DisplayName', CONTACT_METHODS: 'SettingsProfile-ContactMethods', STATUS: 'SettingsProfile-Status', + VACATION_DELEGATE: 'SettingsProfile-VacationDelegate', PRONOUNS: 'SettingsProfile-Pronouns', TIMEZONE: 'SettingsProfile-Timezone', SHARE_CODE: 'SettingsProfile-ShareCode', diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts index aa1fffc6b9c9..a194840337bb 100755 --- a/src/ONYXKEYS.ts +++ b/src/ONYXKEYS.ts @@ -1180,6 +1180,8 @@ const ONYXKEYS = { SETTINGS_STATUS_SET_FORM_DRAFT: 'settingsStatusSetFormDraft', SETTINGS_STATUS_CLEAR_DATE_FORM: 'settingsStatusClearDateForm', SETTINGS_STATUS_CLEAR_DATE_FORM_DRAFT: 'settingsStatusClearDateFormDraft', + VACATION_DELEGATE_FORM: 'vacationDelegateForm', + VACATION_DELEGATE_FORM_DRAFT: 'vacationDelegateFormDraft', CHANGE_BILLING_CURRENCY_FORM: 'billingCurrencyForm', CHANGE_BILLING_CURRENCY_FORM_DRAFT: 'billingCurrencyFormDraft', PRIVATE_NOTES_FORM: 'privateNotesForm', @@ -1417,6 +1419,7 @@ type OnyxFormValuesMapping = { [ONYXKEYS.FORMS.WAYPOINT_FORM]: FormTypes.WaypointForm; [ONYXKEYS.FORMS.SETTINGS_STATUS_SET_FORM]: FormTypes.SettingsStatusSetForm; [ONYXKEYS.FORMS.SETTINGS_STATUS_CLEAR_DATE_FORM]: FormTypes.SettingsStatusClearDateForm; + [ONYXKEYS.FORMS.VACATION_DELEGATE_FORM]: FormTypes.VacationDelegateForm; [ONYXKEYS.FORMS.CHANGE_BILLING_CURRENCY_FORM]: FormTypes.ChangeBillingCurrencyForm; [ONYXKEYS.FORMS.PRIVATE_NOTES_FORM]: FormTypes.PrivateNotesForm; [ONYXKEYS.FORMS.I_KNOW_A_TEACHER_FORM]: FormTypes.IKnowTeacherForm; diff --git a/src/ROUTES.ts b/src/ROUTES.ts index 40eb53dd6ede..90b605323b17 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -2650,8 +2650,9 @@ const ROUTES = { SETTINGS_STATUS_CLEAR_AFTER: 'settings/profile/status/clear-after', SETTINGS_STATUS_CLEAR_AFTER_DATE: 'settings/profile/status/clear-after/date', SETTINGS_STATUS_CLEAR_AFTER_TIME: 'settings/profile/status/clear-after/time', - SETTINGS_VACATION_DELEGATE: 'settings/profile/status/vacation-delegate', - SETTINGS_VACATION_DELEGATE_MISSING_WORKSPACES: 'settings/profile/status/vacation-delegate/missing-workspaces', + SETTINGS_VACATION_DELEGATE: 'settings/profile/vacation-delegate', + SETTINGS_VACATION_DELEGATE_SELECT: 'settings/profile/vacation-delegate/select', + SETTINGS_VACATION_DELEGATE_MISSING_WORKSPACES: 'settings/profile/vacation-delegate/missing-workspaces', SETTINGS_TROUBLESHOOT: 'settings/troubleshoot', SETTINGS_HELP: 'settings/help', @@ -5352,6 +5353,11 @@ const ROUTES = { getRoute: (domainAccountID: number, accountID: number) => `domain/${domainAccountID}/members/${accountID}/vacation-delegate` as const, }, + DOMAIN_VACATION_DELEGATE_SELECT: { + route: 'domain/:domainAccountID/members/:accountID/vacation-delegate/select', + getRoute: (domainAccountID: number, accountID: number) => `domain/${domainAccountID}/members/${accountID}/vacation-delegate/select` as const, + }, + MULTIFACTOR_AUTHENTICATION_AUTHORIZE_TRANSACTION: { route: 'multifactor-authentication/authorize-transaction/:transactionID', getRoute: (transactionID: string) => `multifactor-authentication/authorize-transaction/${transactionID}` as const, diff --git a/src/SCREENS.ts b/src/SCREENS.ts index f39a2448a606..7a4c856d199e 100644 --- a/src/SCREENS.ts +++ b/src/SCREENS.ts @@ -113,7 +113,8 @@ const SCREENS = { STATUS_CLEAR_AFTER: 'Settings_Status_Clear_After', STATUS_CLEAR_AFTER_DATE: 'Settings_Status_Clear_After_Date', STATUS_CLEAR_AFTER_TIME: 'Settings_Status_Clear_After_Time', - VACATION_DELEGATE: 'Settings_Status_Vacation_Delegate', + VACATION_DELEGATE: 'Settings_Profile_Vacation_Delegate', + VACATION_DELEGATE_SELECT: 'Settings_Profile_Vacation_Delegate_Select', VACATION_DELEGATE_MISSING_WORKSPACES: 'Settings_Status_Vacation_Delegate_Missing_Workspaces', STATUS: 'Settings_Status', PRONOUNS: 'Settings_Pronouns', @@ -1190,6 +1191,7 @@ const SCREENS = { MEMBERS: 'Domain_Members', MEMBER_DETAILS: 'Member_Details', VACATION_DELEGATE: 'Vacation_Delegate', + VACATION_DELEGATE_SELECT: 'Vacation_Delegate_Select', RESET_DOMAIN: 'Domain_Reset', ADD_MEMBER: 'Domain_Add_Member', MEMBERS_SETTINGS: 'Members_Settings', diff --git a/src/components/VacationDelegateForm.tsx b/src/components/VacationDelegateForm.tsx new file mode 100644 index 000000000000..caa9b0af7903 --- /dev/null +++ b/src/components/VacationDelegateForm.tsx @@ -0,0 +1,129 @@ +import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; +import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; +import useLocalize from '@hooks/useLocalize'; +import useOnyx from '@hooks/useOnyx'; +import useThemeStyles from '@hooks/useThemeStyles'; + +import {formatVacationDelegateClearDate, getVacationDelegateClearAfter, getVacationDelegateClearDate, isVacationDelegateExpired} from '@libs/VacationDelegateUtils'; +import {getDatePassedError} from '@libs/ValidationUtils'; + +import ONYXKEYS from '@src/ONYXKEYS'; +import INPUT_IDS from '@src/types/form/VacationDelegateForm'; +import type {Errors, PendingAction} from '@src/types/onyx/OnyxCommon'; +import type {BaseVacationDelegate} from '@src/types/onyx/VacationDelegate'; + +import React from 'react'; +import {View} from 'react-native'; + +import type {FormInputErrors, FormOnyxValues} from './Form/types'; + +import DatePicker from './DatePicker'; +import FormProvider from './Form/FormProvider'; +import InputWrapper from './Form/InputWrapper'; +import MenuItemAction from './MenuItem/presets/MenuItemAction'; +import Text from './Text'; +import VacationDelegateMenuItem from './VacationDelegateMenuItem'; + +type VacationDelegateFormProps = { + /** The saved vacation delegate */ + vacationDelegate?: BaseVacationDelegate; + + /** Text shown above the form */ + description?: string; + + /** Opens the member selection screen. The picked member is stored in the form draft. */ + onChangeDelegate: () => void; + + /** + * Saves the picked delegate + * @param delegate - Login of the picked delegate + * @param clearAfter - UTC datetime (yyyy-MM-dd HH:mm:ss) when the delegate clears, or undefined when it never clears + */ + onSubmit: (delegate: string, clearAfter: string | undefined) => void; + + /** Removes the saved delegate */ + onRemove: () => void; + + /** Errors left by the last change to the saved delegate */ + errors?: Errors; + + /** Pending action of the last change to the saved delegate */ + pendingAction?: PendingAction; + + /** Dismisses `errors` */ + onCloseError?: () => void; +}; + +function VacationDelegateForm({vacationDelegate, description, onChangeDelegate, onSubmit, onRemove, errors, pendingAction, onCloseError}: VacationDelegateFormProps) { + const styles = useThemeStyles(); + const {translate, dateFnsLocale} = useLocalize(); + const icons = useMemoizedLazyExpensifyIcons(['Trashcan']); + const {timezone} = useCurrentUserPersonalDetails(); + const [draftValues] = useOnyx(ONYXKEYS.FORMS.VACATION_DELEGATE_FORM_DRAFT); + + const savedDelegate = isVacationDelegateExpired(vacationDelegate?.clearAfter) ? undefined : vacationDelegate?.delegate; + const savedClearDate = savedDelegate ? getVacationDelegateClearDate(vacationDelegate?.clearAfter, timezone?.selected) : ''; + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing -- an empty draft means no member was picked yet + const delegate = draftValues?.[INPUT_IDS.DELEGATE] || savedDelegate; + const formattedClearDate = formatVacationDelegateClearDate(draftValues?.[INPUT_IDS.CLEAR_AFTER_DATE] ?? savedClearDate, dateFnsLocale); + + const validate = (values: FormOnyxValues): FormInputErrors => { + const formErrors: FormInputErrors = {}; + const dateError = values[INPUT_IDS.CLEAR_AFTER_DATE] ? getDatePassedError(translate, values[INPUT_IDS.CLEAR_AFTER_DATE]) : ''; + if (dateError) { + formErrors[INPUT_IDS.CLEAR_AFTER_DATE] = dateError; + } + return formErrors; + }; + + const submit = (values: FormOnyxValues) => { + if (!delegate) { + return; + } + + onSubmit(delegate, getVacationDelegateClearAfter(values[INPUT_IDS.CLEAR_AFTER_DATE], timezone?.selected)); + }; + + return ( + + {!!description && {description}} + + + + {!!formattedClearDate && {translate('statusPage.vacationDelegate.willClearOn', formattedClearDate)}} + + {!!savedDelegate && ( + + )} + + ); +} + +export default VacationDelegateForm; diff --git a/src/components/VacationDelegateMenuItem.tsx b/src/components/VacationDelegateMenuItem.tsx index ff3c58528440..86f17470a16f 100644 --- a/src/components/VacationDelegateMenuItem.tsx +++ b/src/components/VacationDelegateMenuItem.tsx @@ -1,9 +1,11 @@ +import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; import useVacationDelegatePersonalDetails from '@hooks/useVacationDelegatePersonalDetails'; import getVacationDelegateDisplayName from '@libs/getVacationDelegateDisplayName'; +import {formatVacationDelegateClearDate, getVacationDelegateClearDate, isVacationDelegateExpired} from '@libs/VacationDelegateUtils'; import CONST from '@src/CONST'; import type {Errors, PendingAction} from '@src/types/onyx/OnyxCommon'; @@ -29,7 +31,7 @@ type VacationDelegateSectionProps = { /** * Callback used to clear/reset errors related to the vacation delegate */ - onCloseError: () => void; + onCloseError?: () => void; /** * Callback triggered when the section is pressed. @@ -40,15 +42,18 @@ type VacationDelegateSectionProps = { function VacationDelegateMenuItem({vacationDelegate, errors, pendingAction, onCloseError, onPress}: VacationDelegateSectionProps) { const styles = useThemeStyles(); - const {translate, formatPhoneNumber} = useLocalize(); + const {translate, formatPhoneNumber, dateFnsLocale} = useLocalize(); const icons = useMemoizedLazyExpensifyIcons(['FallbackAvatar']); + const {timezone} = useCurrentUserPersonalDetails(); - const hasVacationDelegate = !!vacationDelegate?.delegate; + const hasVacationDelegate = !!vacationDelegate?.delegate && !isVacationDelegateExpired(vacationDelegate?.clearAfter); const vacationDelegatePersonalDetails = useVacationDelegatePersonalDetails(vacationDelegate?.delegate); const rawDelegateLogin = vacationDelegatePersonalDetails?.login ?? vacationDelegate?.delegate ?? ''; const delegateDisplayName = getVacationDelegateDisplayName(rawDelegateLogin, vacationDelegatePersonalDetails?.displayName, formatPhoneNumber); - const delegateDescription = formatPhoneNumber(rawDelegateLogin); + const clearDate = formatVacationDelegateClearDate(getVacationDelegateClearDate(vacationDelegate?.clearAfter, timezone?.selected), dateFnsLocale); + const untilText = clearDate ? translate('statusPage.vacationDelegate.until', clearDate) : ''; + const delegateDescription = [formatPhoneNumber(rawDelegateLogin), untilText].filter(Boolean).join('\n'); return ( {delegateDisplayName} - {!!delegateDescription && {delegateDescription}} + {!!delegateDescription && {delegateDescription}} diff --git a/src/languages/de.ts b/src/languages/de.ts index 1b8f0e88be85..a4190759f992 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -3861,6 +3861,10 @@ ${amount} für ${merchant} – ${date}`, `${delegate} ist kein Mitglied der unten aufgeführten Arbeitsbereiche. Admins der Arbeitsbereiche, die Sie nicht verwalten, werden gebeten, die Person hinzuzufügen. Möchten Sie die Person jetzt zu den Arbeitsbereichen einladen, in denen Sie Admin sind?`, youAreAMemberOf: 'Sie sind Mitglied dieser Arbeitsbereiche:', youAreAnAdminOf: 'Sie sind Admin dieser Arbeitsbereiche:', + clearAfterRecommended: 'Löschen nach (empfohlen)', + willClearOn: (date: string) => `Die Urlaubsvertretung wird am ${date} entfernt.`, + until: (date: string) => `Bis ${date}`, + removeDelegate: 'Urlaubsvertretung entfernen', }, }, stepCounter: (step: number, total?: number, text?: string) => { diff --git a/src/languages/el.ts b/src/languages/el.ts index 150e5abc1e95..c0df52507877 100644 --- a/src/languages/el.ts +++ b/src/languages/el.ts @@ -3911,6 +3911,10 @@ ${amount} για ${merchant} - ${date}`, `Ο/Η ${delegate} δεν είναι μέλος των παρακάτω χώρων εργασίας. Θα ζητηθεί από τους διαχειριστές των χώρων εργασίας που δεν διαχειρίζεστε να τον/την προσθέσουν. Θέλετε να τον/την προσκαλέσετε τώρα σε αυτούς που διαχειρίζεστε ως διαχειριστής;`, youAreAMemberOf: 'Είστε μέλος αυτών των χώρων εργασίας:', youAreAnAdminOf: 'Είστε διαχειριστής αυτών των χώρων εργασίας:', + clearAfterRecommended: 'Εκκαθάριση μετά από (συνιστάται)', + willClearOn: (date: string) => `Ο εκπρόσωπος διακοπών θα καταργηθεί στις ${date}.`, + until: (date: string) => `Έως ${date}`, + removeDelegate: 'Κατάργηση εκπροσώπου διακοπών', }, }, stepCounter: (step, total, text) => { diff --git a/src/languages/en.ts b/src/languages/en.ts index 467b9a867020..6da3472e8c4f 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -3973,6 +3973,10 @@ const translations = { `${delegate} isn't a member of the workspaces below. Admins of the workspaces you don't manage will be asked to add them. Do you want to invite them to the ones you are an admin of now?`, youAreAMemberOf: 'You are a member of these workspaces:', youAreAnAdminOf: 'You are an admin of these workspaces:', + clearAfterRecommended: 'Clear after (recommended)', + willClearOn: (date: string) => `Vacation delegate will clear on ${date}.`, + until: (date: string) => `Until ${date}`, + removeDelegate: 'Remove vacation delegate', }, }, stepCounter: (step: number, total?: number, text?: string) => { diff --git a/src/languages/es.ts b/src/languages/es.ts index f8b9b288a7d6..1379743076d0 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -3779,6 +3779,10 @@ ${amount} para ${merchant} - ${date}`, `${delegate} no es miembro de los siguientes espacios de trabajo. Se pedirá a los administradores de los espacios de trabajo que no gestiona que lo agreguen. ¿Desea invitarlo ahora a los espacios de trabajo en los que es administrador?`, youAreAMemberOf: 'Usted es miembro de estos espacios de trabajo:', youAreAnAdminOf: 'Usted es administrador de estos espacios de trabajo:', + clearAfterRecommended: 'Borrar después de (recomendado)', + willClearOn: (date: string) => `El delegado de vacaciones se eliminará el ${date}.`, + until: (date: string) => `Hasta el ${date}`, + removeDelegate: 'Eliminar delegado de vacaciones', }, }, stepCounter: (step, total, text) => { diff --git a/src/languages/fr.ts b/src/languages/fr.ts index 25aa1b34cabc..263ca6e4933c 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -3869,6 +3869,10 @@ ${amount} pour ${merchant} - ${date}`, `${delegate} n'est pas membre des espaces de travail ci-dessous. Les administrateurs des espaces de travail que vous ne gérez pas seront invités à l'ajouter. Voulez-vous l'inviter dès maintenant à ceux dont vous êtes administrateur ?`, youAreAMemberOf: 'Vous êtes membre de ces espaces de travail :', youAreAnAdminOf: 'Vous êtes administrateur de ces espaces de travail :', + clearAfterRecommended: 'Effacer après (recommandé)', + willClearOn: (date: string) => `Le délégué de vacances sera supprimé le ${date}.`, + until: (date: string) => `Jusqu’au ${date}`, + removeDelegate: 'Supprimer le délégué de vacances', }, }, stepCounter: (step: number, total?: number, text?: string) => { diff --git a/src/languages/it.ts b/src/languages/it.ts index 6064e9b5cc38..37d6575ebf97 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -3842,6 +3842,10 @@ ${amount} per ${merchant} - ${date}`, `${delegate} non è membro degli spazi di lavoro elencati di seguito. Agli amministratori degli spazi di lavoro che non gestisci verrà chiesto di aggiungerlo. Vuoi invitarlo ora in quelli di cui sei amministratore?`, youAreAMemberOf: 'Sei membro di questi spazi di lavoro:', youAreAnAdminOf: 'Sei amministratore di questi spazi di lavoro:', + clearAfterRecommended: 'Cancella dopo (consigliato)', + willClearOn: (date: string) => `La delega ferie verrà rimossa il ${date}.`, + until: (date: string) => `Fino al ${date}`, + removeDelegate: 'Rimuovi delega ferie', }, }, stepCounter: (step: number, total?: number, text?: string) => { diff --git a/src/languages/ja.ts b/src/languages/ja.ts index 042a7c0d4ab0..335360623851 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -3811,6 +3811,10 @@ ${integrationName === CONST.ONBOARDING_ACCOUNTING_MAPPING.other ? 'あなたの' `${delegate}は以下のワークスペースのメンバーではありません。あなたが管理していないワークスペースの管理者に追加を依頼します。あなたが管理者になっているワークスペースには今すぐ招待しますか?`, youAreAMemberOf: 'あなたはこれらのワークスペースのメンバーです:', youAreAnAdminOf: 'あなたはこれらのワークスペースの管理者です:', + clearAfterRecommended: 'クリア日(推奨)', + willClearOn: (date: string) => `休暇代理人は${date}に解除されます。`, + until: (date: string) => `${date}まで`, + removeDelegate: '休暇代理人を削除', }, }, stepCounter: (step: number, total?: number, text?: string) => { diff --git a/src/languages/nl.ts b/src/languages/nl.ts index eae278301c6c..682476d39dde 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -3844,6 +3844,10 @@ ${amount} voor ${merchant} - ${date}`, `${delegate} is geen lid van de onderstaande werkruimtes. Beheerders van de werkruimtes die je niet beheert, wordt gevraagd diegene toe te voegen. Wil je diegene nu uitnodigen voor de werkruimtes waar je beheerder van bent?`, youAreAMemberOf: 'Je bent lid van deze werkruimtes:', youAreAnAdminOf: 'Je bent beheerder van deze werkruimtes:', + clearAfterRecommended: 'Wissen na (aanbevolen)', + willClearOn: (date: string) => `De vertegenwoordiger tijdens vakantie wordt gewist op ${date}.`, + until: (date: string) => `Tot ${date}`, + removeDelegate: 'Vertegenwoordiger tijdens vakantie verwijderen', }, }, stepCounter: (step: number, total?: number, text?: string) => { diff --git a/src/languages/pl.ts b/src/languages/pl.ts index 482f1f887b4a..057cfe15a4be 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -3873,6 +3873,10 @@ ${amount} dla ${merchant} - ${date}`, `${delegate} nie jest członkiem poniższych przestrzeni roboczych. Administratorzy przestrzeni roboczych, którymi nie zarządzasz, zostaną poproszeni o dodanie tej osoby. Czy chcesz zaprosić tę osobę do przestrzeni, w których jesteś administratorem?`, youAreAMemberOf: 'Jesteś członkiem tych przestrzeni roboczych:', youAreAnAdminOf: 'Jesteś administratorem tych przestrzeni roboczych:', + clearAfterRecommended: 'Wyczyść po (zalecane)', + willClearOn: (date: string) => `Zastępca urlopowy zostanie usunięty ${date}.`, + until: (date: string) => `Do ${date}`, + removeDelegate: 'Usuń zastępcę urlopowego', }, }, stepCounter: (step: number, total?: number, text?: string) => { diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index ce56c5cad2bc..d69f8dc7e152 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -3831,6 +3831,10 @@ ${amount} para ${merchant} - ${date}`, `${delegate} não é membro dos workspaces abaixo. Os admins dos workspaces que você não gerencia serão solicitados a adicioná-lo(a). Deseja convidá-lo(a) agora para os workspaces em que você é admin?`, youAreAMemberOf: 'Você é membro destes workspaces:', youAreAnAdminOf: 'Você é admin destes workspaces:', + clearAfterRecommended: 'Limpar após (recomendado)', + willClearOn: (date: string) => `O delegado de férias será removido em ${date}.`, + until: (date: string) => `Até ${date}`, + removeDelegate: 'Remover delegado de férias', }, }, stepCounter: (step: number, total?: number, text?: string) => { diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index ba103eb5ca9e..419603777b44 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -3719,6 +3719,10 @@ ${amount},商户:${merchant} - 日期:${date}`, `${delegate} 不是以下工作区的成员。系统会请求你未管理的工作区的管理员将其添加进来。你要现在邀请他们加入你管理的工作区吗?`, youAreAMemberOf: '你是这些工作区的成员:', youAreAnAdminOf: '你是这些工作区的管理员:', + clearAfterRecommended: '清除日期(推荐)', + willClearOn: (date: string) => `休假代理将于 ${date} 清除。`, + until: (date: string) => `直到 ${date}`, + removeDelegate: '移除休假代理', }, }, stepCounter: (step: number, total?: number, text?: string) => { diff --git a/src/libs/API/parameters/SetVacationDelegateParams.ts b/src/libs/API/parameters/SetVacationDelegateParams.ts index d149f370d5c5..eb5abb05c555 100644 --- a/src/libs/API/parameters/SetVacationDelegateParams.ts +++ b/src/libs/API/parameters/SetVacationDelegateParams.ts @@ -2,6 +2,9 @@ type SetVacationDelegateParams = { creator: string; vacationerEmail?: string; vacationDelegateEmail: string; + + /** UTC datetime (yyyy-MM-dd HH:mm:ss) when the backend should clear the vacation delegate */ + clearAfter?: string; overridePolicyDiffWarning: boolean; domainAccountID?: number; }; diff --git a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx index 126cbac1a383..7689f03c411c 100644 --- a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx +++ b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx @@ -529,7 +529,8 @@ const SettingsModalStackNavigator = createModalStackNavigator require('../../../../pages/settings/Profile/CustomStatus/StatusClearAfterPage').default, [SCREENS.SETTINGS.PROFILE.STATUS_CLEAR_AFTER_DATE]: () => require('../../../../pages/settings/Profile/CustomStatus/SetDatePage').default, [SCREENS.SETTINGS.PROFILE.STATUS_CLEAR_AFTER_TIME]: () => require('../../../../pages/settings/Profile/CustomStatus/SetTimePage').default, - [SCREENS.SETTINGS.PROFILE.VACATION_DELEGATE]: () => require('../../../../pages/settings/Profile/CustomStatus/VacationDelegatePage').default, + [SCREENS.SETTINGS.PROFILE.VACATION_DELEGATE]: () => require('../../../../pages/settings/Profile/CustomStatus/VacationDelegateFormPage').default, + [SCREENS.SETTINGS.PROFILE.VACATION_DELEGATE_SELECT]: () => require('../../../../pages/settings/Profile/CustomStatus/VacationDelegatePage').default, [SCREENS.SETTINGS.PROFILE.VACATION_DELEGATE_MISSING_WORKSPACES]: () => require('../../../../pages/settings/Profile/CustomStatus/VacationDelegateMissingWorkspacesPage').default, [SCREENS.SETTINGS.SUBSCRIPTION.SIZE]: () => require('../../../../pages/settings/Subscription/SubscriptionSize').default, @@ -1257,7 +1258,8 @@ const SettingsModalStackNavigator = createModalStackNavigator require('../../../../pages/domain/Admins/DomainAddAdminPage').default, [SCREENS.DOMAIN.MEMBER_DETAILS]: () => require('../../../../pages/domain/Members/DomainMemberDetailsPage').default, [SCREENS.DOMAIN.RESET_DOMAIN]: () => require('../../../../pages/domain/DomainResetPage').default, - [SCREENS.DOMAIN.VACATION_DELEGATE]: () => require('../../../../pages/domain/Members/DomainMemberVacationDelegatePage').default, + [SCREENS.DOMAIN.VACATION_DELEGATE]: () => require('../../../../pages/domain/Members/DomainMemberVacationDelegateFormPage').default, + [SCREENS.DOMAIN.VACATION_DELEGATE_SELECT]: () => require('../../../../pages/domain/Members/DomainMemberVacationDelegatePage').default, [SCREENS.DOMAIN.ADD_MEMBER]: () => require('../../../../pages/domain/Members/DomainAddMemberPage').default, [SCREENS.DOMAIN.MEMBERS_SETTINGS]: () => require('../../../../pages/domain/Members/DomainMembersSettingsPage').default, [SCREENS.DOMAIN.MEMBERS_SETTINGS_TWO_FACTOR_AUTH]: () => require('../../../../pages/domain/Members/DomainRequireTwoFactorAuthPage').default, diff --git a/src/libs/Navigation/linkingConfig/RELATIONS/DOMAIN_TO_RHP.ts b/src/libs/Navigation/linkingConfig/RELATIONS/DOMAIN_TO_RHP.ts index 572d7444556e..17f92ba79351 100755 --- a/src/libs/Navigation/linkingConfig/RELATIONS/DOMAIN_TO_RHP.ts +++ b/src/libs/Navigation/linkingConfig/RELATIONS/DOMAIN_TO_RHP.ts @@ -14,6 +14,7 @@ const DOMAIN_TO_RHP: Partial['config'] = { path: ROUTES.SETTINGS_VACATION_DELEGATE, exact: true, }, + [SCREENS.SETTINGS.PROFILE.VACATION_DELEGATE_SELECT]: { + path: ROUTES.SETTINGS_VACATION_DELEGATE_SELECT, + exact: true, + }, [SCREENS.SETTINGS.PROFILE.VACATION_DELEGATE_MISSING_WORKSPACES]: { path: ROUTES.SETTINGS_VACATION_DELEGATE_MISSING_WORKSPACES, exact: true, @@ -1555,6 +1559,9 @@ const config: LinkingOptions['config'] = { [SCREENS.DOMAIN.VACATION_DELEGATE]: { path: ROUTES.DOMAIN_VACATION_DELEGATE.route, }, + [SCREENS.DOMAIN.VACATION_DELEGATE_SELECT]: { + path: ROUTES.DOMAIN_VACATION_DELEGATE_SELECT.route, + }, [SCREENS.DOMAIN.ADD_MEMBER]: { path: ROUTES.DOMAIN_ADD_MEMBER.route, }, diff --git a/src/libs/Navigation/types.ts b/src/libs/Navigation/types.ts index 201be0b09617..f38d3ce23f85 100644 --- a/src/libs/Navigation/types.ts +++ b/src/libs/Navigation/types.ts @@ -341,6 +341,7 @@ type SettingsNavigatorParamList = { [SCREENS.SETTINGS.PROFILE.STATUS_CLEAR_AFTER_DATE]: undefined; [SCREENS.SETTINGS.PROFILE.STATUS_CLEAR_AFTER_TIME]: undefined; [SCREENS.SETTINGS.PROFILE.VACATION_DELEGATE]: undefined; + [SCREENS.SETTINGS.PROFILE.VACATION_DELEGATE_SELECT]: undefined; [SCREENS.SETTINGS.PROFILE.VACATION_DELEGATE_MISSING_WORKSPACES]: undefined; [SCREENS.WORKSPACE.CURRENCY]: { isForcedToChangeCurrency?: boolean; @@ -2104,6 +2105,10 @@ type SettingsNavigatorParamList = { domainAccountID: number; accountID: number; }; + [SCREENS.DOMAIN.VACATION_DELEGATE_SELECT]: { + domainAccountID: number; + accountID: number; + }; [SCREENS.DOMAIN.MEMBER_FORCE_TWO_FACTOR_AUTH]: { domainAccountID: number; accountID: number; diff --git a/src/libs/VacationDelegateUtils.ts b/src/libs/VacationDelegateUtils.ts new file mode 100644 index 000000000000..cef40ef540d6 --- /dev/null +++ b/src/libs/VacationDelegateUtils.ts @@ -0,0 +1,54 @@ +import CONST from '@src/CONST'; +import type {SelectedTimezone} from '@src/types/onyx/PersonalDetails'; + +import type {Locale as DateFnsLocale} from 'date-fns'; + +import {format, isValid, parse} from 'date-fns'; + +import DateUtils from './DateUtils'; + +/** + * Converts the date picked for the vacation delegate to clear on (yyyy-MM-dd) into the UTC datetime the backend expects. + * The delegate stays active for the whole picked day, so this is the end of that day in the given timezone. + */ +function getVacationDelegateClearAfter(clearDate: string | undefined, timezone: SelectedTimezone | undefined): string | undefined { + if (!clearDate) { + return undefined; + } + + return DateUtils.normalizeDateToEndOfDay(clearDate, timezone ?? CONST.DEFAULT_TIME_ZONE.selected); +} + +/** + * Converts the UTC datetime the vacation delegate clears after back into the day (yyyy-MM-dd) it clears on in the given timezone. + */ +function getVacationDelegateClearDate(clearAfter: string | undefined, timezone: SelectedTimezone | undefined): string { + if (!clearAfter) { + return ''; + } + + return DateUtils.formatUTCDateTimeToDateInTimezone(clearAfter, timezone ?? CONST.DEFAULT_TIME_ZONE.selected); +} + +/** + * Formats a clear date (yyyy-MM-dd) for display, e.g. "Oct 1, 2026". + */ +function formatVacationDelegateClearDate(clearDate: string, dateFnsLocale: DateFnsLocale | undefined): string { + const date = parse(clearDate, CONST.DATE.FNS_FORMAT_STRING, new Date()); + if (!isValid(date)) { + return ''; + } + + return format(date, CONST.DATE.MONTH_DAY_YEAR_ABBR_FORMAT, {locale: dateFnsLocale}); +} + +/** + * Whether the vacation delegate's clearAfter datetime has passed. The backend clears the delegate at that time, so this only covers + * the gap before its update reaches the client. + */ +function isVacationDelegateExpired(clearAfter: string | undefined): boolean { + // Both values are UTC in the database format, so they compare as strings. + return !!clearAfter && clearAfter <= DateUtils.getDBTime(); +} + +export {getVacationDelegateClearAfter, getVacationDelegateClearDate, formatVacationDelegateClearDate, isVacationDelegateExpired}; diff --git a/src/libs/actions/Domain.ts b/src/libs/actions/Domain.ts index d950eb15f721..f0fc6f31b7c5 100644 --- a/src/libs/actions/Domain.ts +++ b/src/libs/actions/Domain.ts @@ -1660,7 +1660,18 @@ function clearValidateDomainTwoFactorCodeError() { Onyx.set(ONYXKEYS.VALIDATE_DOMAIN_TWO_FACTOR_CODE, null); } -function setDomainVacationDelegate(domainAccountID: number, domainMemberAccountID: number, creator: string, vacationer: string, delegate: string, vacationDelegate?: BaseVacationDelegate) { +/** + * @param clearAfter - UTC datetime (yyyy-MM-dd HH:mm:ss) when the backend should clear the delegate. No value means it never clears. + */ +function setDomainVacationDelegate( + domainAccountID: number, + domainMemberAccountID: number, + creator: string, + vacationer: string, + delegate: string, + vacationDelegate?: BaseVacationDelegate, + clearAfter?: string, +) { const optimisticData: Array> = [ { onyxMethod: Onyx.METHOD.MERGE, @@ -1669,9 +1680,11 @@ function setDomainVacationDelegate(domainAccountID: number, domainMemberAccountI [`${CONST.DOMAIN.PRIVATE_VACATION_DELEGATE_PREFIX}${domainMemberAccountID}`]: { delegate, creator, + clearAfter: clearAfter ?? null, previousDelegate: vacationDelegate?.delegate, + previousClearAfter: vacationDelegate?.clearAfter ?? null, }, - } as PrefixedRecord, + } as PrefixedRecord>, }, { onyxMethod: Onyx.METHOD.MERGE, @@ -1704,6 +1717,7 @@ function setDomainVacationDelegate(domainAccountID: number, domainMemberAccountI value: { [`${CONST.DOMAIN.PRIVATE_VACATION_DELEGATE_PREFIX}${domainMemberAccountID}`]: { previousDelegate: null, + previousClearAfter: null, }, } as PrefixedRecord>, }, @@ -1760,6 +1774,7 @@ function setDomainVacationDelegate(domainAccountID: number, domainMemberAccountI creator, vacationerEmail: vacationer, vacationDelegateEmail: delegate, + clearAfter, overridePolicyDiffWarning: true, domainAccountID, }; @@ -1776,7 +1791,9 @@ function deleteDomainVacationDelegate(domainAccountID: number, domainMemberAccou [`${CONST.DOMAIN.PRIVATE_VACATION_DELEGATE_PREFIX}${domainMemberAccountID}`]: { creator: null, delegate: null, + clearAfter: null, previousDelegate: vacationDelegate?.delegate, + previousClearAfter: vacationDelegate?.clearAfter ?? null, }, } as PrefixedRecord>, }, @@ -1864,10 +1881,11 @@ function deleteDomainVacationDelegate(domainAccountID: number, domainMemberAccou API.write(WRITE_COMMANDS.DELETE_VACATION_DELEGATE, {vacationerEmail: vacationer, domainAccountID}, {optimisticData, successData, failureData}); } -function clearVacationDelegateError(domainAccountID: number, domainMemberAccountID: number, domainMemberEmail: string, previousDelegate?: string) { +function clearVacationDelegateError(domainAccountID: number, domainMemberAccountID: number, domainMemberEmail: string, previousDelegate?: string, previousClearAfter?: string) { Onyx.merge(`${ONYXKEYS.COLLECTION.DOMAIN}${domainAccountID}`, { [`${CONST.DOMAIN.PRIVATE_VACATION_DELEGATE_PREFIX}${domainMemberAccountID}`]: { delegate: previousDelegate ?? null, + clearAfter: previousClearAfter ?? null, }, } as PrefixedRecord>); diff --git a/src/libs/actions/VacationDelegate.ts b/src/libs/actions/VacationDelegate.ts index fb2247b32693..cb2c8eb38727 100644 --- a/src/libs/actions/VacationDelegate.ts +++ b/src/libs/actions/VacationDelegate.ts @@ -27,11 +27,15 @@ import {addMembersToWorkspace} from './Policy/Member'; type SetVacationDelegateOptions = { creator: string; delegate: string; + + /** UTC datetime (yyyy-MM-dd HH:mm:ss) when the backend should clear the delegate. No value means it never clears. */ + clearAfter?: string; currentDelegate?: string; + currentClearAfter?: string; shouldOverridePolicyDiffWarning?: boolean; }; -async function setVacationDelegate({creator, delegate, currentDelegate, shouldOverridePolicyDiffWarning = false}: SetVacationDelegateOptions) { +async function setVacationDelegate({creator, delegate, clearAfter, currentDelegate, currentClearAfter, shouldOverridePolicyDiffWarning = false}: SetVacationDelegateOptions) { const optimisticData: Array> = [ { onyxMethod: Onyx.METHOD.MERGE, @@ -39,11 +43,14 @@ async function setVacationDelegate({creator, delegate, currentDelegate, shouldOv value: { creator, delegate, + clearAfter: clearAfter ?? null, errors: null, pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, previousDelegate: currentDelegate ?? null, + previousClearAfter: currentClearAfter ?? null, policyDiff: null, pendingDelegate: null, + pendingClearAfter: null, }, }, ]; @@ -56,8 +63,10 @@ async function setVacationDelegate({creator, delegate, currentDelegate, shouldOv errors: null, pendingAction: null, previousDelegate: null, + previousClearAfter: null, policyDiff: null, pendingDelegate: null, + pendingClearAfter: null, }, }, ]; @@ -76,6 +85,7 @@ async function setVacationDelegate({creator, delegate, currentDelegate, shouldOv const parameters: SetVacationDelegateParams = { creator, vacationDelegateEmail: delegate, + clearAfter, overridePolicyDiffWarning: shouldOverridePolicyDiffWarning, }; @@ -106,7 +116,9 @@ async function setVacationDelegate({creator, delegate, currentDelegate, shouldOv Onyx.merge(ONYXKEYS.NVP_PRIVATE_VACATION_DELEGATE, { errors: null, delegate: currentDelegate ?? null, + clearAfter: currentClearAfter ?? null, pendingDelegate: delegate, + pendingClearAfter: clearAfter ?? null, policyDiff: response.data.policyDiff, pendingAction: null, }); @@ -122,7 +134,7 @@ function deleteVacationDelegate(vacationDelegate?: VacationDelegate) { return; } - const {creator, delegate} = vacationDelegate; + const {creator, delegate, clearAfter} = vacationDelegate; const optimisticData: Array> = [ { onyxMethod: Onyx.METHOD.MERGE, @@ -130,11 +142,14 @@ function deleteVacationDelegate(vacationDelegate?: VacationDelegate) { value: { creator: null, delegate: null, + clearAfter: null, errors: null, previousDelegate: vacationDelegate?.delegate, + previousClearAfter: clearAfter ?? null, pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE, policyDiff: null, pendingDelegate: null, + pendingClearAfter: null, }, }, ]; @@ -147,6 +162,7 @@ function deleteVacationDelegate(vacationDelegate?: VacationDelegate) { errors: null, pendingAction: null, previousDelegate: null, + previousClearAfter: null, }, }, ]; @@ -158,6 +174,7 @@ function deleteVacationDelegate(vacationDelegate?: VacationDelegate) { value: { creator, delegate, + clearAfter, errors: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('statusPage.vacationDelegateError'), pendingAction: null, }, @@ -167,14 +184,17 @@ function deleteVacationDelegate(vacationDelegate?: VacationDelegate) { API.write(WRITE_COMMANDS.DELETE_VACATION_DELEGATE, null, {optimisticData, successData, failureData}); } -function clearVacationDelegateError(previousDelegate?: string) { +function clearVacationDelegateError(previousDelegate?: string, previousClearAfter?: string) { Onyx.merge(ONYXKEYS.NVP_PRIVATE_VACATION_DELEGATE, { errors: null, pendingAction: null, delegate: previousDelegate ?? null, + clearAfter: previousClearAfter ?? null, previousDelegate: null, + previousClearAfter: null, policyDiff: null, pendingDelegate: null, + pendingClearAfter: null, }); } diff --git a/src/pages/domain/Members/DomainMemberDetailsPage.tsx b/src/pages/domain/Members/DomainMemberDetailsPage.tsx index e2b202899662..86bb9fbe1368 100644 --- a/src/pages/domain/Members/DomainMemberDetailsPage.tsx +++ b/src/pages/domain/Members/DomainMemberDetailsPage.tsx @@ -21,8 +21,10 @@ import { closeUserAccount, setTwoFactorAuthExemptEmailForDomain, } from '@libs/actions/Domain'; +import {clearDraftValues} from '@libs/actions/FormActions'; import {requestUnlockAccount} from '@libs/actions/User'; import {getLatestError} from '@libs/ErrorUtils'; +import {isVacationDelegateExpired} from '@libs/VacationDelegateUtils'; import Navigation from '@navigation/Navigation'; import type {PlatformStackScreenProps} from '@navigation/PlatformStackNavigation/types'; @@ -168,10 +170,18 @@ function DomainMemberDetailsPage({route}: DomainMemberDetailsPageProps) { Navigation.navigate(ROUTES.DOMAIN_VACATION_DELEGATE.getRoute(domainAccountID, accountID))} + onPress={() => { + clearDraftValues(ONYXKEYS.FORMS.VACATION_DELEGATE_FORM); + // With no delegate there is nothing to show on the form yet, so go straight to picking one. + Navigation.navigate( + vacationDelegate?.delegate && !isVacationDelegateExpired(vacationDelegate.clearAfter) + ? ROUTES.DOMAIN_VACATION_DELEGATE.getRoute(domainAccountID, accountID) + : ROUTES.DOMAIN_VACATION_DELEGATE_SELECT.getRoute(domainAccountID, accountID), + ); + }} pendingAction={domainPendingActions?.member?.[memberLogin]?.vacationDelegate} errors={getLatestError(domainErrors?.memberErrors?.[memberLogin]?.vacationDelegateErrors)} - onCloseError={() => clearVacationDelegateError(domainAccountID, accountID, memberLogin, vacationDelegate?.previousDelegate)} + onCloseError={() => clearVacationDelegateError(domainAccountID, accountID, memberLogin, vacationDelegate?.previousDelegate, vacationDelegate?.previousClearAfter)} /> {!!domainSettings?.twoFactorAuthRequired && ( ; + +function DomainMemberVacationDelegateFormPage({route}: DomainMemberVacationDelegateFormPageProps) { + const {domainAccountID, accountID} = route.params; + const {translate} = useLocalize(); + + const {login: currentUserLogin} = useCurrentUserPersonalDetails(); + + const [vacationDelegate] = useOnyx(`${ONYXKEYS.COLLECTION.DOMAIN}${domainAccountID}`, { + selector: vacationDelegateSelector(accountID), + }); + + const [personalDetails] = usePersonalDetail(accountID); + const memberLogin = personalDetails?.login; + + const goBackToMemberDetails = () => { + clearDraftValues(ONYXKEYS.FORMS.VACATION_DELEGATE_FORM); + Navigation.goBack(ROUTES.DOMAIN_MEMBER_DETAILS.getRoute(domainAccountID, accountID)); + }; + + const onSubmit = (delegate: string, clearAfter: string | undefined) => { + if (!memberLogin) { + return; + } + + setDomainVacationDelegate(domainAccountID, accountID, currentUserLogin ?? '', memberLogin, delegate, vacationDelegate, clearAfter); + goBackToMemberDetails(); + }; + + const onRemove = () => { + if (!memberLogin || !vacationDelegate) { + return; + } + + deleteDomainVacationDelegate(domainAccountID, accountID, memberLogin, vacationDelegate); + goBackToMemberDetails(); + }; + + return ( + + + + Navigation.navigate(ROUTES.DOMAIN_VACATION_DELEGATE_SELECT.getRoute(domainAccountID, accountID))} + onSubmit={onSubmit} + onRemove={onRemove} + /> + + + ); +} + +export default DomainMemberVacationDelegateFormPage; diff --git a/src/pages/domain/Members/DomainMemberVacationDelegatePage.tsx b/src/pages/domain/Members/DomainMemberVacationDelegatePage.tsx index 704f53ecac29..ea70afb247f7 100644 --- a/src/pages/domain/Members/DomainMemberVacationDelegatePage.tsx +++ b/src/pages/domain/Members/DomainMemberVacationDelegatePage.tsx @@ -1,10 +1,10 @@ import BaseVacationDelegateSelectionComponent from '@components/BaseVacationDelegateSelectionComponent'; import ScreenWrapper from '@components/ScreenWrapper'; -import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; +import {setDraftValues} from '@libs/actions/FormActions'; import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackScreenProps} from '@navigation/PlatformStackNavigation/types'; @@ -12,28 +12,31 @@ import type {SettingsNavigatorParamList} from '@navigation/types'; import DomainNotFoundPageWrapper from '@pages/domain/DomainNotFoundPageWrapper'; -import {deleteDomainVacationDelegate, setDomainVacationDelegate} from '@userActions/Domain'; - import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import type SCREENS from '@src/SCREENS'; +import type {VacationDelegateForm} from '@src/types/form'; +import INPUT_IDS from '@src/types/form/VacationDelegateForm'; import type {Participant} from '@src/types/onyx/IOU'; +import type {OnyxEntry} from 'react-native-onyx'; + import {vacationDelegateSelector} from '@selectors/Domain'; import {personalDetailsSelector} from '@selectors/PersonalDetails'; import React from 'react'; -type DomainMemberVacationDelegatePageProps = PlatformStackScreenProps; +type DomainMemberVacationDelegatePageProps = PlatformStackScreenProps; + +const draftDelegateSelector = (draft: OnyxEntry) => draft?.[INPUT_IDS.DELEGATE]; function DomainMemberVacationDelegatePage({route}: DomainMemberVacationDelegatePageProps) { const {domainAccountID, accountID} = route.params; const {translate} = useLocalize(); - const {login: currentUserLogin} = useCurrentUserPersonalDetails(); - const [vacationDelegate] = useOnyx(`${ONYXKEYS.COLLECTION.DOMAIN}${domainAccountID}`, { selector: vacationDelegateSelector(accountID), }); + const [draftDelegate] = useOnyx(ONYXKEYS.FORMS.VACATION_DELEGATE_FORM_DRAFT, {selector: draftDelegateSelector}); const [personalDetails] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST, { selector: personalDetailsSelector(accountID), @@ -47,14 +50,9 @@ function DomainMemberVacationDelegatePage({route}: DomainMemberVacationDelegateP return; } - if (delegateLogin === vacationDelegate?.delegate) { - deleteDomainVacationDelegate(domainAccountID, accountID, memberLogin, vacationDelegate); - Navigation.goBack(ROUTES.DOMAIN_MEMBER_DETAILS.getRoute(domainAccountID, accountID)); - return; - } - - setDomainVacationDelegate(domainAccountID, accountID, currentUserLogin ?? '', memberLogin, delegateLogin, vacationDelegate); - Navigation.goBack(ROUTES.DOMAIN_MEMBER_DETAILS.getRoute(domainAccountID, accountID)); + // The pick is only saved from the form, where the clear after date is chosen too. + setDraftValues(ONYXKEYS.FORMS.VACATION_DELEGATE_FORM, {[INPUT_IDS.DELEGATE]: delegateLogin}); + Navigation.goBack(ROUTES.DOMAIN_VACATION_DELEGATE.getRoute(domainAccountID, accountID)); }; return ( @@ -64,10 +62,10 @@ function DomainMemberVacationDelegatePage({route}: DomainMemberVacationDelegateP testID="DomainMemberVacationDelegate" > Navigation.goBack(ROUTES.DOMAIN_MEMBER_DETAILS.getRoute(domainAccountID, accountID))} + onBackButtonPress={() => Navigation.goBack()} cannotSetDelegateMessage={translate('domain.members.cannotSetVacationDelegateForMember', memberLogin ?? '')} additionalExcludeLogins={memberLogin ? {[memberLogin]: true} : undefined} /> diff --git a/src/pages/settings/Profile/CustomStatus/StatusPage.tsx b/src/pages/settings/Profile/CustomStatus/StatusPage.tsx index 813c0630b014..4f4d4fe7d58e 100644 --- a/src/pages/settings/Profile/CustomStatus/StatusPage.tsx +++ b/src/pages/settings/Profile/CustomStatus/StatusPage.tsx @@ -1,5 +1,4 @@ import Button from '@components/Button'; -import DelegatorList from '@components/DelegatorList'; import EmojiPickerButtonDropdown from '@components/EmojiPicker/EmojiPickerButtonDropdown'; import FixedFooter from '@components/FixedFooter'; import FormProvider from '@components/Form/FormProvider'; @@ -11,7 +10,6 @@ import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; import ScreenWrapper from '@components/ScreenWrapper'; import Text from '@components/Text'; import TextInput from '@components/TextInput'; -import VacationDelegateMenuItem from '@components/VacationDelegateMenuItem'; import useAutoFocusInput from '@hooks/useAutoFocusInput'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; @@ -27,11 +25,9 @@ import {isMobileChrome} from '@libs/Browser'; import DateUtils from '@libs/DateUtils'; import focusAfterModalClose from '@libs/focusAfterModalClose'; import focusComposerWithDelay from '@libs/focusComposerWithDelay'; -import getVacationDelegateErrors from '@libs/getVacationDelegateErrors'; import Navigation from '@libs/Navigation/Navigation'; import {clearCustomStatus, clearDraftCustomStatus, updateCustomStatus, updateDraftCustomStatus} from '@userActions/User'; -import {clearVacationDelegateError} from '@userActions/VacationDelegate'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -66,8 +62,6 @@ function StatusPage() { const formRef = useRef(null); const [brickRoadIndicator, setBrickRoadIndicator] = useState>(); - const [vacationDelegate] = useOnyx(ONYXKEYS.NVP_PRIVATE_VACATION_DELEGATE); - const hasActiveDelegations = !!vacationDelegate?.delegatorFor?.length; const isFormLoading = !!formState?.isLoading; const currentUserEmojiCode = currentUserPersonalDetails?.status?.emojiCode ?? ''; @@ -244,27 +238,6 @@ function StatusPage() { /> )} - - {translate('common.vacationDelegate')} - {hasActiveDelegations ? ( - - ) : ( - <> - {translate('statusPage.setVacationDelegate')} - clearVacationDelegateError(vacationDelegate?.previousDelegate)} - onPress={() => Navigation.navigate(ROUTES.SETTINGS_VACATION_DELEGATE)} - /> - - )} - - {isInLandscapeMode && saveButton} diff --git a/src/pages/settings/Profile/CustomStatus/VacationDelegateFormPage.tsx b/src/pages/settings/Profile/CustomStatus/VacationDelegateFormPage.tsx new file mode 100644 index 000000000000..a5a752e52aaa --- /dev/null +++ b/src/pages/settings/Profile/CustomStatus/VacationDelegateFormPage.tsx @@ -0,0 +1,123 @@ +import HeaderWithBackButton from '@components/HeaderWithBackButton'; +import ScreenWrapper from '@components/ScreenWrapper'; +import VacationDelegateForm from '@components/VacationDelegateForm'; + +import useConfirmModal from '@hooks/useConfirmModal'; +import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; +import useLocalize from '@hooks/useLocalize'; +import useOnyx from '@hooks/useOnyx'; + +import {clearDraftValues} from '@libs/actions/FormActions'; +import {clearVacationDelegateError, deleteVacationDelegate, setVacationDelegate} from '@libs/actions/VacationDelegate'; +import getVacationDelegateErrors from '@libs/getVacationDelegateErrors'; +import Navigation from '@libs/Navigation/Navigation'; + +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import ROUTES from '@src/ROUTES'; +import {isEmptyObject} from '@src/types/utils/EmptyObject'; + +import {useNavigation} from '@react-navigation/native'; +import React, {useRef} from 'react'; + +function goBackToProfile() { + clearDraftValues(ONYXKEYS.FORMS.VACATION_DELEGATE_FORM); + Navigation.goBack(ROUTES.SETTINGS_PROFILE.route); +} + +function VacationDelegateFormPage() { + const {translate} = useLocalize(); + const {login: currentUserLogin = ''} = useCurrentUserPersonalDetails(); + const {showConfirmModal} = useConfirmModal(); + const navigation = useNavigation(); + + const [vacationDelegate] = useOnyx(ONYXKEYS.NVP_PRIVATE_VACATION_DELEGATE); + + const isSubmittingRef = useRef(false); + + const showErrorModal = async (delegateToRestore?: string, clearAfterToRestore?: string, message?: string) => { + await showConfirmModal({ + title: translate('statusPage.addVacationDelegate'), + prompt: message ?? translate('statusPage.vacationDelegateError'), + confirmText: translate('common.buttonConfirm'), + shouldShowCancelButton: false, + }); + + clearVacationDelegateError(delegateToRestore, clearAfterToRestore); + }; + + const onSubmit = (delegate: string, clearAfter: string | undefined) => { + if (isSubmittingRef.current) { + return; + } + + isSubmittingRef.current = true; + const hasUnconfirmedChange = !!vacationDelegate?.pendingAction || !isEmptyObject(vacationDelegate?.errors) || !!vacationDelegate?.policyDiff; + const currentDelegate = hasUnconfirmedChange ? vacationDelegate?.previousDelegate : vacationDelegate?.delegate; + const currentClearAfter = hasUnconfirmedChange ? vacationDelegate?.previousClearAfter : vacationDelegate?.clearAfter; + setVacationDelegate({creator: currentUserLogin, delegate, clearAfter, currentDelegate, currentClearAfter}) + .then((response) => { + if (!navigation.isFocused()) { + if (response?.data?.policyDiff) { + clearVacationDelegateError(currentDelegate, currentClearAfter); + } + return; + } + + if (response?.data?.policyDiff) { + Navigation.navigate(ROUTES.SETTINGS_VACATION_DELEGATE_MISSING_WORKSPACES); + return; + } + + // The action leaves the failure on the NVP for the profile page's red brick road, but the user is still on this screen, + // so report it where they are. Dismissing the modal restores the previous delegate, exactly as dismissing that error would. + if (response?.jsonCode !== CONST.JSON_CODE.SUCCESS) { + showErrorModal(currentDelegate, currentClearAfter, response?.jsonCode === CONST.JSON_CODE.EXP_ERROR ? response.message : undefined); + return; + } + + goBackToProfile(); + }) + .catch(() => { + if (!navigation.isFocused()) { + clearVacationDelegateError(currentDelegate, currentClearAfter); + return; + } + + showErrorModal(currentDelegate, currentClearAfter); + }) + .finally(() => { + isSubmittingRef.current = false; + }); + }; + + const onRemove = () => { + deleteVacationDelegate(vacationDelegate); + goBackToProfile(); + }; + + return ( + + + Navigation.navigate(ROUTES.SETTINGS_VACATION_DELEGATE_SELECT)} + onSubmit={onSubmit} + onRemove={onRemove} + errors={getVacationDelegateErrors(vacationDelegate)} + pendingAction={vacationDelegate?.pendingAction} + onCloseError={() => clearVacationDelegateError(vacationDelegate?.previousDelegate, vacationDelegate?.previousClearAfter)} + /> + + ); +} + +export default VacationDelegateFormPage; diff --git a/src/pages/settings/Profile/CustomStatus/VacationDelegateMissingWorkspacesPage/index.tsx b/src/pages/settings/Profile/CustomStatus/VacationDelegateMissingWorkspacesPage/index.tsx index 9668e61dab40..5cf1fdefe535 100644 --- a/src/pages/settings/Profile/CustomStatus/VacationDelegateMissingWorkspacesPage/index.tsx +++ b/src/pages/settings/Profile/CustomStatus/VacationDelegateMissingWorkspacesPage/index.tsx @@ -13,6 +13,7 @@ import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; import useThemeStyles from '@hooks/useThemeStyles'; +import {clearDraftValues} from '@libs/actions/FormActions'; import {openWorkspaceMembersPage} from '@libs/actions/Policy/Member'; import {clearVacationDelegateError, inviteVacationDelegateToWorkspaces, setVacationDelegate} from '@libs/actions/VacationDelegate'; import Navigation from '@libs/Navigation/Navigation'; @@ -37,6 +38,7 @@ import WorkspaceSection from './WorkspaceSection'; type ScreenInput = { delegate: string; + clearAfter?: string; policyDiff: VacationDelegatePolicyDiff; }; @@ -52,7 +54,9 @@ function VacationDelegateMissingWorkspacesPage() { const creator = currentUserPersonalDetails.login ?? ''; const delegate = submittedInput?.delegate ?? vacationDelegate?.pendingDelegate ?? ''; + const clearAfter = submittedInput ? submittedInput.clearAfter : vacationDelegate?.pendingClearAfter; const previousDelegate = vacationDelegate?.previousDelegate; + const previousClearAfter = vacationDelegate?.previousClearAfter; const policyDiff = submittedInput?.policyDiff ?? vacationDelegate?.policyDiff; const adminPolicies = policyDiff?.adminPolicies ?? []; const nonAdminPolicies = policyDiff?.nonAdminPolicies ?? []; @@ -89,8 +93,8 @@ function VacationDelegateMissingWorkspacesPage() { usePreventRemove(!!policyDiff, ({data}: {data: {action: NavigationAction}}) => { if (!isSubmittingRef.current && policyDiff) { - setSubmittedInput({delegate, policyDiff}); - clearVacationDelegateError(previousDelegate); + setSubmittedInput({delegate, clearAfter, policyDiff}); + clearVacationDelegateError(previousDelegate, previousClearAfter); } navigation.dispatch(data.action); @@ -126,9 +130,10 @@ function VacationDelegateMissingWorkspacesPage() { }); } - setSubmittedInput({delegate, policyDiff}); - setVacationDelegate({creator, delegate, currentDelegate: previousDelegate, shouldOverridePolicyDiffWarning: true}); - Navigation.goBack(ROUTES.SETTINGS_STATUS); + setSubmittedInput({delegate, clearAfter, policyDiff}); + setVacationDelegate({creator, delegate, clearAfter, currentDelegate: previousDelegate, currentClearAfter: previousClearAfter, shouldOverridePolicyDiffWarning: true}); + clearDraftValues(ONYXKEYS.FORMS.VACATION_DELEGATE_FORM); + Navigation.goBack(ROUTES.SETTINGS_PROFILE.route); }; return ( diff --git a/src/pages/settings/Profile/CustomStatus/VacationDelegatePage.tsx b/src/pages/settings/Profile/CustomStatus/VacationDelegatePage.tsx index d87256cec670..07260bb0542c 100644 --- a/src/pages/settings/Profile/CustomStatus/VacationDelegatePage.tsx +++ b/src/pages/settings/Profile/CustomStatus/VacationDelegatePage.tsx @@ -1,92 +1,38 @@ import BaseVacationDelegateSelectionComponent from '@components/BaseVacationDelegateSelectionComponent'; import ScreenWrapper from '@components/ScreenWrapper'; -import useConfirmModal from '@hooks/useConfirmModal'; -import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; -import {clearVacationDelegateError, deleteVacationDelegate, setVacationDelegate} from '@libs/actions/VacationDelegate'; +import {setDraftValues} from '@libs/actions/FormActions'; import Navigation from '@libs/Navigation/Navigation'; -import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; +import type {VacationDelegateForm} from '@src/types/form'; +import INPUT_IDS from '@src/types/form/VacationDelegateForm'; import type {Participant} from '@src/types/onyx/IOU'; -import {isEmptyObject} from '@src/types/utils/EmptyObject'; -import {useNavigation} from '@react-navigation/native'; -import React, {useRef} from 'react'; +import type {OnyxEntry} from 'react-native-onyx'; + +import React from 'react'; + +const draftDelegateSelector = (draft: OnyxEntry) => draft?.[INPUT_IDS.DELEGATE]; function VacationDelegatePage() { const {translate} = useLocalize(); - const {login: currentUserLogin = ''} = useCurrentUserPersonalDetails(); - const {showConfirmModal} = useConfirmModal(); - const navigation = useNavigation(); const [vacationDelegate] = useOnyx(ONYXKEYS.NVP_PRIVATE_VACATION_DELEGATE); - - const isSelectingRef = useRef(false); - - const showErrorModal = async (delegateToRestore?: string, message?: string) => { - await showConfirmModal({ - title: translate('statusPage.addVacationDelegate'), - prompt: message ?? translate('statusPage.vacationDelegateError'), - confirmText: translate('common.buttonConfirm'), - shouldShowCancelButton: false, - }); - - clearVacationDelegateError(delegateToRestore); - }; + const [draftDelegate] = useOnyx(ONYXKEYS.FORMS.VACATION_DELEGATE_FORM_DRAFT, {selector: draftDelegateSelector}); const onSelectRow = (option: Participant) => { - if (isSelectingRef.current) { + if (!option?.login) { return; } - if (option?.login === vacationDelegate?.delegate) { - deleteVacationDelegate(vacationDelegate); - Navigation.goBack(ROUTES.SETTINGS_STATUS); - return; - } - - isSelectingRef.current = true; - const hasUnconfirmedChange = !!vacationDelegate?.pendingAction || !isEmptyObject(vacationDelegate?.errors) || !!vacationDelegate?.policyDiff; - const currentDelegate = hasUnconfirmedChange ? vacationDelegate?.previousDelegate : vacationDelegate?.delegate; - setVacationDelegate({creator: currentUserLogin, delegate: option?.login ?? '', currentDelegate}) - .then((response) => { - if (!navigation.isFocused()) { - if (response?.data?.policyDiff) { - clearVacationDelegateError(currentDelegate); - } - return; - } - - if (response?.data?.policyDiff) { - Navigation.navigate(ROUTES.SETTINGS_VACATION_DELEGATE_MISSING_WORKSPACES); - return; - } - - // The action leaves the failure on the NVP for the profile page's red brick road, but the user is still on this screen, - // so report it where they are. Dismissing the modal restores the previous delegate, exactly as dismissing that error would. - if (response?.jsonCode !== CONST.JSON_CODE.SUCCESS) { - showErrorModal(currentDelegate, response?.jsonCode === CONST.JSON_CODE.EXP_ERROR ? response.message : undefined); - return; - } - - Navigation.goBack(ROUTES.SETTINGS_STATUS); - }) - .catch(() => { - if (!navigation.isFocused()) { - clearVacationDelegateError(currentDelegate); - return; - } - - showErrorModal(currentDelegate); - }) - .finally(() => { - isSelectingRef.current = false; - }); + // The pick is only saved from the form, where the clear after date is chosen too. + setDraftValues(ONYXKEYS.FORMS.VACATION_DELEGATE_FORM, {[INPUT_IDS.DELEGATE]: option.login}); + Navigation.goBack(ROUTES.SETTINGS_VACATION_DELEGATE); }; return ( @@ -96,10 +42,10 @@ function VacationDelegatePage() { shouldShowOfflineIndicator={false} > Navigation.goBack(ROUTES.SETTINGS_STATUS)} + onBackButtonPress={() => Navigation.goBack()} cannotSetDelegateMessage={translate('statusPage.cannotSetVacationDelegate')} includeCurrentUser={false} /> diff --git a/src/pages/settings/Profile/ProfilePage.tsx b/src/pages/settings/Profile/ProfilePage.tsx index fbe2ff443f38..a8b3e03db4d3 100755 --- a/src/pages/settings/Profile/ProfilePage.tsx +++ b/src/pages/settings/Profile/ProfilePage.tsx @@ -29,7 +29,10 @@ import useScrollEnabled from '@hooks/useScrollEnabled'; import useStyleUtils from '@hooks/useStyleUtils'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; +import useVacationDelegatePersonalDetails from '@hooks/useVacationDelegatePersonalDetails'; +import {clearDraftValues} from '@libs/actions/FormActions'; +import getVacationDelegateDisplayName from '@libs/getVacationDelegateDisplayName'; import getVacationDelegateErrors from '@libs/getVacationDelegateErrors'; import createDynamicRoute from '@libs/Navigation/helpers/dynamicRoutesUtils/createDynamicRoute'; import Navigation from '@libs/Navigation/Navigation'; @@ -37,6 +40,7 @@ import type {PlatformStackRouteProp} from '@libs/Navigation/PlatformStackNavigat import type {SettingsSplitNavigatorParamList} from '@libs/Navigation/types'; import {getFormattedAddress, temporaryGetDisplayNameOrDefault} from '@libs/PersonalDetailsUtils'; import {expensifyLoginsSelector, getContactMethodsOptions, getLoginListBrickRoadIndicator} from '@libs/UserUtils'; +import {formatVacationDelegateClearDate, getVacationDelegateClearDate, isVacationDelegateExpired} from '@libs/VacationDelegateUtils'; import useTimeSensitiveHomeAddress from '@pages/home/TimeSensitiveSection/hooks/useTimeSensitiveHomeAddress'; @@ -67,7 +71,7 @@ function ProfilePage() { const theme = useTheme(); const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); - const {translate, formatPhoneNumber} = useLocalize(); + const {translate, formatPhoneNumber, dateFnsLocale} = useLocalize(); const {shouldUseNarrowLayout} = useResponsiveLayout(); const {safeAreaPaddingBottomStyle} = useSafeAreaPaddings(); const scrollEnabled = useScrollEnabled(); @@ -99,12 +103,21 @@ function ProfilePage() { const [commuterExclusionsWorkspaceName] = useOnyx(ONYXKEYS.COLLECTION.POLICY, {selector: homeAndOfficeCommuterExclusionPolicyNameSelector}); const [vacationDelegate] = useOnyx(ONYXKEYS.NVP_PRIVATE_VACATION_DELEGATE); + const hasVacationDelegate = !!vacationDelegate?.delegate && !isVacationDelegateExpired(vacationDelegate?.clearAfter); + const vacationDelegatePersonalDetails = useVacationDelegatePersonalDetails(hasVacationDelegate ? vacationDelegate?.delegate : undefined); + const vacationDelegateName = hasVacationDelegate + ? getVacationDelegateDisplayName(vacationDelegatePersonalDetails?.login ?? vacationDelegate?.delegate ?? '', vacationDelegatePersonalDetails?.displayName, formatPhoneNumber) + : ''; + const vacationDelegateClearDate = hasVacationDelegate + ? formatVacationDelegateClearDate(getVacationDelegateClearDate(vacationDelegate?.clearAfter, currentUserPersonalDetails?.timezone?.selected), dateFnsLocale) + : ''; const {isActingAsDelegate} = useDelegateNoAccessState(); const {showDelegateNoAccessModal} = useDelegateNoAccessActions(); const publicOptions: Array<{ description: string; title: string; pageRoute?: Route; + onPress?: () => void; brickRoadIndicator?: ValueOf; testID?: string; sentryLabel?: string; @@ -131,10 +144,22 @@ function ProfilePage() { description: translate('statusPage.status'), title: emojiCode ? `${emojiCode} ${currentUserPersonalDetails?.status?.text ?? ''}` : '', pageRoute: ROUTES.SETTINGS_STATUS, - brickRoadIndicator: isEmptyObject(getVacationDelegateErrors(vacationDelegate)) ? undefined : CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR, testID: 'status-menu-item', sentryLabel: CONST.SENTRY_LABEL.SETTINGS_PROFILE.STATUS, }, + { + description: translate('common.vacationDelegate'), + title: vacationDelegateClearDate ? `${vacationDelegateName} · ${translate('statusPage.vacationDelegate.until', vacationDelegateClearDate)}` : vacationDelegateName, + // With no delegate there is nothing to show on the form yet, so go straight to picking one. + pageRoute: hasVacationDelegate ? ROUTES.SETTINGS_VACATION_DELEGATE : ROUTES.SETTINGS_VACATION_DELEGATE_SELECT, + onPress: () => { + clearDraftValues(ONYXKEYS.FORMS.VACATION_DELEGATE_FORM); + Navigation.navigate(hasVacationDelegate ? ROUTES.SETTINGS_VACATION_DELEGATE : ROUTES.SETTINGS_VACATION_DELEGATE_SELECT); + }, + brickRoadIndicator: isEmptyObject(getVacationDelegateErrors(vacationDelegate)) ? undefined : CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR, + testID: 'vacation-delegate-menu-item', + sentryLabel: CONST.SENTRY_LABEL.SETTINGS_PROFILE.VACATION_DELEGATE, + }, ...(isAgentAccount === false ? [ { @@ -279,7 +304,7 @@ function ProfilePage() { return ( Navigation.navigate(pageRoute) : undefined} + onPress={detail.onPress ?? (pageRoute ? () => Navigation.navigate(pageRoute) : undefined)} testID={detail?.testID} sentryLabel={detail.sentryLabel} > diff --git a/src/types/form/VacationDelegateForm.ts b/src/types/form/VacationDelegateForm.ts new file mode 100644 index 000000000000..3c6bb6b2906a --- /dev/null +++ b/src/types/form/VacationDelegateForm.ts @@ -0,0 +1,24 @@ +import type {ValueOf} from 'type-fest'; + +import type Form from './Form'; + +const INPUT_IDS = { + /** Delegate picked on the member selection screen, before it is saved */ + DELEGATE: 'delegate', + + /** Day (yyyy-MM-dd) the vacation delegate clears on, in the timezone of the person setting it */ + CLEAR_AFTER_DATE: 'clearAfterDate', +} as const; + +type InputID = ValueOf; + +type VacationDelegateForm = Form< + InputID, + { + [INPUT_IDS.DELEGATE]: string; + [INPUT_IDS.CLEAR_AFTER_DATE]: string; + } +>; + +export type {VacationDelegateForm}; +export default INPUT_IDS; diff --git a/src/types/form/index.ts b/src/types/form/index.ts index 47b94b46c3e7..bbacf3aa50b1 100644 --- a/src/types/form/index.ts +++ b/src/types/form/index.ts @@ -37,6 +37,7 @@ export type {DebugTransactionViolationForm} from './DebugTransactionViolationFor export type {RoomNameForm} from './RoomNameForm'; export type {SettingsStatusClearDateForm} from './SettingsStatusClearDateForm'; export type {SettingsStatusSetForm} from './SettingsStatusSetForm'; +export type {VacationDelegateForm} from './VacationDelegateForm'; export type {WaypointForm} from './WaypointForm'; export type {WorkspaceInviteMessageForm} from './WorkspaceInviteMessageForm'; export type {WorkspaceCategoryForm} from './WorkspaceCategoryForm'; diff --git a/src/types/onyx/VacationDelegate.ts b/src/types/onyx/VacationDelegate.ts index 565181bce169..639c86b37f9c 100644 --- a/src/types/onyx/VacationDelegate.ts +++ b/src/types/onyx/VacationDelegate.ts @@ -23,6 +23,12 @@ type BaseVacationDelegate = { /** Previous delegate for rollback on failure */ previousDelegate?: string; + /** When the backend clears the vacation delegate, as a UTC datetime (yyyy-MM-dd HH:mm:ss). No value means it never clears. */ + clearAfter?: string; + + /** Previous clearAfter for rollback on failure */ + previousClearAfter?: string; + /** Workspaces the delegate is missing from, captured from the 305 response. Client-only. */ policyDiff?: VacationDelegatePolicyDiff; @@ -31,6 +37,9 @@ type BaseVacationDelegate = { * Kept apart from `delegate` so an abandoned step never makes an unsaved pick read as saved. Client-only. */ pendingDelegate?: string; + + /** Clear after datetime picked with `pendingDelegate`, so the missing workspaces step can send it again. Client-only. */ + pendingClearAfter?: string; }; /** Information about vacation delegate with offline feedback */ diff --git a/tests/actions/DomainTest.ts b/tests/actions/DomainTest.ts index fdb1d6937351..348a44789864 100644 --- a/tests/actions/DomainTest.ts +++ b/tests/actions/DomainTest.ts @@ -761,7 +761,7 @@ describe('actions/Domain', () => { optimisticData: expect.arrayContaining([ expect.objectContaining({ key: `${ONYXKEYS.COLLECTION.DOMAIN}${domainAccountID}`, - value: {[PRIVATE_VACATION_DELEGATE_KEY]: {delegate, creator, previousDelegate: undefined}}, + value: {[PRIVATE_VACATION_DELEGATE_KEY]: {delegate, creator, clearAfter: null, previousDelegate: undefined, previousClearAfter: null}}, }), expect.objectContaining({ key: `${ONYXKEYS.COLLECTION.DOMAIN_PENDING_ACTIONS}${domainAccountID}`, @@ -775,7 +775,7 @@ describe('actions/Domain', () => { successData: expect.arrayContaining([ expect.objectContaining({ key: `${ONYXKEYS.COLLECTION.DOMAIN}${domainAccountID}`, - value: {[PRIVATE_VACATION_DELEGATE_KEY]: {previousDelegate: null}}, + value: {[PRIVATE_VACATION_DELEGATE_KEY]: {previousDelegate: null, previousClearAfter: null}}, }), expect.objectContaining({ key: `${ONYXKEYS.COLLECTION.DOMAIN_ERRORS}${domainAccountID}`, @@ -834,6 +834,46 @@ describe('actions/Domain', () => { }); }); + describe('setDomainVacationDelegate with clearAfter', () => { + it('sends clearAfter and keeps the old one for rollback', () => { + // Given a domain member whose delegate already clears on a date + const apiWriteSpy = jest.spyOn(require('@libs/API'), 'write').mockImplementation(() => Promise.resolve()); + const domainAccountID = 123; + const domainMemberAccountID = 456; + const vacationer = 'vacationer@test.com'; + const clearAfter = '2026-10-02 06:59:59'; + const vacationDelegate: BaseVacationDelegate = {delegate: 'old@test.com', creator: 'admin@test.com', clearAfter: '2026-09-30 06:59:59'}; + const PRIVATE_VACATION_DELEGATE_KEY = + `${CONST.DOMAIN.PRIVATE_VACATION_DELEGATE_PREFIX}${domainMemberAccountID}` as const satisfies `${typeof CONST.DOMAIN.PRIVATE_VACATION_DELEGATE_PREFIX}${string}`; + + // When the admin saves a new delegate with a new clear after datetime + setDomainVacationDelegate(domainAccountID, domainMemberAccountID, 'admin@test.com', vacationer, 'new@test.com', vacationDelegate, clearAfter); + + // Then the backend gets the datetime to clear the delegate at, and the old datetime is kept so dismissing an error can restore it + expect(apiWriteSpy).toHaveBeenCalledWith( + WRITE_COMMANDS.SET_VACATION_DELEGATE, + expect.objectContaining({vacationDelegateEmail: 'new@test.com', clearAfter}), + expect.objectContaining({ + optimisticData: expect.arrayContaining([ + expect.objectContaining({ + key: `${ONYXKEYS.COLLECTION.DOMAIN}${domainAccountID}`, + value: { + [PRIVATE_VACATION_DELEGATE_KEY]: expect.objectContaining({ + delegate: 'new@test.com', + clearAfter, + previousDelegate: 'old@test.com', + previousClearAfter: '2026-09-30 06:59:59', + }), + }, + }), + ]), + }), + ); + + apiWriteSpy.mockRestore(); + }); + }); + describe('deleteDomainVacationDelegate', () => { it('deleteDomainVacationDelegate - sends DELETE_VACATION_DELEGATE request with correct data', () => { const apiWriteSpy = jest.spyOn(require('@libs/API'), 'write').mockImplementation(() => Promise.resolve()); @@ -853,7 +893,9 @@ describe('actions/Domain', () => { optimisticData: expect.arrayContaining([ expect.objectContaining({ key: `${ONYXKEYS.COLLECTION.DOMAIN}${domainAccountID}`, - value: {[PRIVATE_VACATION_DELEGATE_KEY]: {creator: null, delegate: null, previousDelegate: vacationDelegate.delegate}}, + value: { + [PRIVATE_VACATION_DELEGATE_KEY]: {creator: null, delegate: null, clearAfter: null, previousDelegate: vacationDelegate.delegate, previousClearAfter: null}, + }, }), expect.objectContaining({ key: `${ONYXKEYS.COLLECTION.DOMAIN_PENDING_ACTIONS}${domainAccountID}`, diff --git a/tests/actions/VacationDelegateTest.ts b/tests/actions/VacationDelegateTest.ts index 9fb4c261e0c7..fe5be969f750 100644 --- a/tests/actions/VacationDelegateTest.ts +++ b/tests/actions/VacationDelegateTest.ts @@ -215,6 +215,31 @@ describe('actions/VacationDelegate', () => { expect(vacationDelegate?.pendingDelegate).toBe('delegate@test.com'); }); + it('parks the picked clear after datetime next to the pick on a 305 and puts the saved one back', async () => { + // Given a saved delegate that clears on one date, and a mocked 305 for a pick that clears on another + await Onyx.set(ONYXKEYS.NVP_PRIVATE_VACATION_DELEGATE, {creator: 'admin@test.com', delegate: 'old@test.com', clearAfter: '2026-09-30 06:59:59'}); + jest.spyOn(require('@libs/API'), 'makeRequestWithSideEffects').mockImplementation(async () => ({ + jsonCode: CONST.JSON_CODE.POLICY_DIFF_WARNING, + data: {policyDiff: {adminPolicies: ['1'], nonAdminPolicies: []}}, + })); + + // When the new delegate is picked with its clear after datetime + await setVacationDelegate({ + creator: 'admin@test.com', + delegate: 'delegate@test.com', + clearAfter: '2026-10-02 06:59:59', + currentDelegate: 'old@test.com', + currentClearAfter: '2026-09-30 06:59:59', + }); + await waitForBatchedUpdates(); + + // Then the saved datetime stays in place, since the backend saved nothing, and the picked one waits with the pick + // so the missing workspaces step can send it again + const vacationDelegate = await getOnyxValue(ONYXKEYS.NVP_PRIVATE_VACATION_DELEGATE); + expect(vacationDelegate?.clearAfter).toBe('2026-09-30 06:59:59'); + expect(vacationDelegate?.pendingClearAfter).toBe('2026-10-02 06:59:59'); + }); + it('applies the failureData it could not attach when the response fails, since the caller may have already navigated away', async () => { // Given a mocked non-305 error response, with no failureData attached to the request (see the first test // above), so the action has to apply it itself instead of relying on a caller that may no longer be mounted @@ -351,5 +376,28 @@ describe('actions/VacationDelegate', () => { expect(vacationDelegate?.policyDiff).toBeFalsy(); expect(vacationDelegate?.pendingDelegate).toBeFalsy(); }); + + it('restores the previous clear after datetime along with the previous delegate', async () => { + // Given a failed change that replaced both the delegate and the datetime it clears after + const timestamp = 123; + await Onyx.set(ONYXKEYS.NVP_PRIVATE_VACATION_DELEGATE, { + delegate: 'delegate@test.com', + clearAfter: '2026-10-02 06:59:59', + previousDelegate: 'old@test.com', + previousClearAfter: '2026-09-30 06:59:59', + pendingClearAfter: '2026-10-02 06:59:59', + errors: {[timestamp]: 'Some error'}, + }); + + // When the error is dismissed + clearVacationDelegateError('old@test.com', '2026-09-30 06:59:59'); + await waitForBatchedUpdates(); + + // Then the last confirmed datetime is back, so the profile does not show a date the backend never saved + const vacationDelegate = await getOnyxValue(ONYXKEYS.NVP_PRIVATE_VACATION_DELEGATE); + expect(vacationDelegate?.clearAfter).toBe('2026-09-30 06:59:59'); + expect(vacationDelegate?.previousClearAfter).toBeFalsy(); + expect(vacationDelegate?.pendingClearAfter).toBeFalsy(); + }); }); }); diff --git a/tests/ui/ProfilePageTest.tsx b/tests/ui/ProfilePageTest.tsx index 65c74b67d065..6c0b22138a22 100644 --- a/tests/ui/ProfilePageTest.tsx +++ b/tests/ui/ProfilePageTest.tsx @@ -325,6 +325,43 @@ describe('ProfilePage - agent account', () => { expect(screen.getByText('Private')).toBeDefined(); }); + it('opens the member picker from the vacation delegate row when no delegate is set', async () => { + // Given a user with no vacation delegate + await setupUser('user@expensify.com'); + + renderPageWithNavigation(SCREENS.SETTINGS.PROFILE.ROOT); + await waitForBatchedUpdatesWithAct(); + + // When the vacation delegate row is pressed + // MenuItem only forwards the press to onPress when it receives an event, so pass a minimal one. + fireEvent.press(screen.getByTestId('vacation-delegate-menu-item'), {nativeEvent: {}}); + await waitForBatchedUpdatesWithAct(); + + // Then the member picker opens directly, since the form has nothing to show until a member is picked + expect(Navigation.navigate).toHaveBeenCalledWith(ROUTES.SETTINGS_VACATION_DELEGATE_SELECT); + }); + + it('shows the vacation delegate and when it clears, and opens the form from the row', async () => { + // Given a user whose vacation delegate clears in the future + await setupUser('user@expensify.com'); + await act(async () => { + await Onyx.set(ONYXKEYS.NVP_PRIVATE_VACATION_DELEGATE, {creator: 'user@expensify.com', delegate: 'delegate@expensify.com', clearAfter: '2999-01-02 07:59:59'}); + }); + + renderPageWithNavigation(SCREENS.SETTINGS.PROFILE.ROOT); + await waitForBatchedUpdatesWithAct(); + + // Then the row shows the delegate and the end date, so the user can see reports are routed elsewhere + expect(screen.getByText(/delegate@expensify\.com · Until/)).toBeDefined(); + + // When the row is pressed + fireEvent.press(screen.getByTestId('vacation-delegate-menu-item'), {nativeEvent: {}}); + await waitForBatchedUpdatesWithAct(); + + // Then the form opens, where the date can be changed or the delegate removed + expect(Navigation.navigate).toHaveBeenCalledWith(ROUTES.SETTINGS_VACATION_DELEGATE); + }); + it('shows AI prompt section with prompt text for agent account', async () => { const accountID = 123; await setupUser('testbot_123@expensify.ai', true); diff --git a/tests/ui/VacationDelegateFormPageTest.tsx b/tests/ui/VacationDelegateFormPageTest.tsx new file mode 100644 index 000000000000..d7c454b002c7 --- /dev/null +++ b/tests/ui/VacationDelegateFormPageTest.tsx @@ -0,0 +1,386 @@ +import {act, fireEvent, render, screen} from '@testing-library/react-native'; + +import ComposeProviders from '@components/ComposeProviders'; +import {CurrentUserPersonalDetailsProvider} from '@components/CurrentUserPersonalDetailsProvider'; +import {LocaleContextProvider} from '@components/LocaleContextProvider'; +import OnyxListItemProvider from '@components/OnyxListItemProvider'; + +import {SIDE_EFFECT_REQUEST_COMMANDS, WRITE_COMMANDS} from '@libs/API/types'; +import {getMicroSecondOnyxErrorWithTranslationKey} from '@libs/ErrorUtils'; +import Navigation from '@libs/Navigation/Navigation'; + +import VacationDelegateFormPage from '@pages/settings/Profile/CustomStatus/VacationDelegateFormPage'; + +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import ROUTES from '@src/ROUTES'; + +import type * as ReactNavigation from '@react-navigation/native'; +// eslint-disable-next-line no-restricted-imports -- React Native Pressable/Text are required only to type the actual Jest module export; this does not import them at runtime. +import type {Pressable as ReactNativePressable, Text as ReactNativeText} from 'react-native'; + +import {createNavigationContainerRef, NavigationContainer} from '@react-navigation/native'; +import {createStackNavigator} from '@react-navigation/stack'; +import React from 'react'; +import Onyx from 'react-native-onyx'; + +import getOnyxValue from '../utils/getOnyxValue'; +import * as TestHelper from '../utils/TestHelper'; +import waitForBatchedUpdatesWithAct from '../utils/waitForBatchedUpdatesWithAct'; + +const CREATOR_ACCOUNT_ID = 1; +const CREATOR_EMAIL = 'creator@example.com'; +const DELEGATE_A_EMAIL = 'delegateA@example.com'; +const DELEGATE_B_EMAIL = 'delegateB@example.com'; +const ORIGINAL_DELEGATE_EMAIL = 'original@example.com'; +const CLEAR_AFTER = '2026-10-02 06:59:59'; +const ORIGINAL_CLEAR_AFTER = '2026-09-30 06:59:59'; + +jest.mock('@libs/Navigation/Navigation', () => ({ + navigate: jest.fn(), + goBack: jest.fn(), + getActiveRoute: jest.fn(() => ''), + getActiveRouteWithoutParams: jest.fn(() => ''), + isNavigationReady: jest.fn(() => Promise.resolve()), +})); + +jest.mock('@react-navigation/native', () => { + const actualNavigation: typeof ReactNavigation = jest.requireActual('@react-navigation/native'); + + return { + ...actualNavigation, + useRoute: jest.fn(() => ({params: {}})), + usePreventRemove: jest.fn(), + }; +}); + +// The real confirm modal is bridged through the global modal system, which needs Navigation methods this file's +// lightweight Navigation mock doesn't provide. Stub the hook instead so tests can assert on what VacationDelegateFormPage +// asks it to show without pulling in that machinery. +const mockShowConfirmModal = jest.fn(); +jest.mock('@hooks/useConfirmModal', () => + jest.fn(() => ({ + showConfirmModal: mockShowConfirmModal, + })), +); + +// Replaces the real form, with its date picker and member row, with plain pressable buttons so tests can +// simulate rapid saves on VacationDelegateFormPage's onSubmit without driving the full form UI. +jest.mock('@components/VacationDelegateForm', () => { + const ReactMock = jest.requireActual('react'); + const {Pressable, Text} = jest.requireActual<{Pressable: typeof ReactNativePressable; Text: typeof ReactNativeText}>('react-native'); + + return ({onSubmit, onRemove}: {onSubmit: (delegate: string, clearAfter: string | undefined) => void; onRemove: () => void}) => + ReactMock.createElement( + ReactMock.Fragment, + null, + ReactMock.createElement(Pressable, {testID: 'select-delegate-a', onPress: () => onSubmit('delegateA@example.com', undefined)}, ReactMock.createElement(Text, null, 'select-a')), + ReactMock.createElement(Pressable, {testID: 'select-delegate-b', onPress: () => onSubmit('delegateB@example.com', undefined)}, ReactMock.createElement(Text, null, 'select-b')), + ReactMock.createElement( + Pressable, + {testID: 'select-delegate-b-with-date', onPress: () => onSubmit('delegateB@example.com', '2026-10-02 06:59:59')}, + ReactMock.createElement(Text, null, 'select-b-with-date'), + ), + ReactMock.createElement(Pressable, {testID: 'remove-delegate', onPress: onRemove}, ReactMock.createElement(Text, null, 'remove')), + ); +}); + +function renderPage() { + return render( + + + + + , + ); +} + +// A real Stack.Navigator so pushing "Other" genuinely blurs VacationDelegateFormPage's own useNavigation().isFocused(), +// unlike renderPage() above where the page is the sole, permanently-focused screen. +type TestParamList = { + VacationDelegate: undefined; + Other: undefined; +}; +const Stack = createStackNavigator(); +const testNavigationRef = createNavigationContainerRef(); + +function renderPageWithStack() { + return render( + + + + + null} + /> + + + , + ); +} + +describe('VacationDelegateFormPage', () => { + let apiSideEffectSpy: jest.SpyInstance; + + beforeAll(() => { + Onyx.init({keys: ONYXKEYS}); + }); + + beforeEach(async () => { + mockShowConfirmModal.mockReset().mockResolvedValue({action: 'CLOSE'}); + await act(async () => { + await Onyx.set(ONYXKEYS.NVP_PREFERRED_LOCALE, CONST.LOCALES.EN); + }); + await TestHelper.signInWithTestUser(CREATOR_ACCOUNT_ID, CREATOR_EMAIL); + await waitForBatchedUpdatesWithAct(); + }); + + afterEach(async () => { + jest.restoreAllMocks(); + await act(async () => { + await Onyx.clear(); + }); + await waitForBatchedUpdatesWithAct(); + }); + + it('calls setVacationDelegate once for a single selection', async () => { + // Given a signed-in creator (see beforeEach) and a side-effect request mocked to succeed, so only what the + // page sends and does next is under test + apiSideEffectSpy = jest.spyOn(require('@libs/API'), 'makeRequestWithSideEffects').mockImplementation(() => Promise.resolve({jsonCode: CONST.JSON_CODE.SUCCESS})); + + renderPage(); + await waitForBatchedUpdatesWithAct(); + + // When a delegate row is tapped + fireEvent.press(screen.getByTestId('select-delegate-a')); + await waitForBatchedUpdatesWithAct(); + + // Then setVacationDelegate is sent exactly once with the mapped params, and the page navigates back to the profile page + expect(apiSideEffectSpy).toHaveBeenCalledTimes(1); + expect(apiSideEffectSpy).toHaveBeenCalledWith( + SIDE_EFFECT_REQUEST_COMMANDS.SET_VACATION_DELEGATE, + expect.objectContaining({creator: CREATOR_EMAIL, vacationDelegateEmail: DELEGATE_A_EMAIL}), + expect.anything(), + ); + expect(Navigation.goBack).toHaveBeenCalledWith(ROUTES.SETTINGS_PROFILE.route); + }); + + it('sends the clear after datetime and keeps the confirmed one for rollback', async () => { + // Given a saved delegate that already clears on a date, and a request that never resolves so the optimistic write stays in place + apiSideEffectSpy = jest.spyOn(require('@libs/API'), 'makeRequestWithSideEffects').mockImplementation(() => new Promise(() => {})); + await act(async () => { + await Onyx.set(ONYXKEYS.NVP_PRIVATE_VACATION_DELEGATE, {creator: CREATOR_EMAIL, delegate: ORIGINAL_DELEGATE_EMAIL, clearAfter: ORIGINAL_CLEAR_AFTER}); + }); + + renderPage(); + await waitForBatchedUpdatesWithAct(); + + // When a new delegate is saved with a clear after date + fireEvent.press(screen.getByTestId('select-delegate-b-with-date')); + await waitForBatchedUpdatesWithAct(); + + // Then the backend gets the datetime to clear the delegate at, and the confirmed datetime is kept so a failure can restore it + expect(apiSideEffectSpy).toHaveBeenLastCalledWith( + SIDE_EFFECT_REQUEST_COMMANDS.SET_VACATION_DELEGATE, + expect.objectContaining({vacationDelegateEmail: DELEGATE_B_EMAIL, clearAfter: CLEAR_AFTER}), + expect.objectContaining({ + optimisticData: [ + expect.objectContaining({ + value: expect.objectContaining({ + delegate: DELEGATE_B_EMAIL, + clearAfter: CLEAR_AFTER, + previousDelegate: ORIGINAL_DELEGATE_EMAIL, + previousClearAfter: ORIGINAL_CLEAR_AFTER, + }), + }), + ], + }), + ); + }); + + it('removes the saved delegate and goes back to the profile page', async () => { + // Given a saved delegate + const apiWriteSpy = jest.spyOn(require('@libs/API'), 'write').mockImplementation(() => Promise.resolve()); + jest.mocked(Navigation.goBack).mockClear(); + await act(async () => { + await Onyx.set(ONYXKEYS.NVP_PRIVATE_VACATION_DELEGATE, {creator: CREATOR_EMAIL, delegate: ORIGINAL_DELEGATE_EMAIL}); + }); + + renderPage(); + await waitForBatchedUpdatesWithAct(); + + // When the remove button is pressed + fireEvent.press(screen.getByTestId('remove-delegate')); + await waitForBatchedUpdatesWithAct(); + + // Then the delegate is deleted, since removal now lives on the form instead of re-picking the same member + expect(apiWriteSpy).toHaveBeenCalledWith(WRITE_COMMANDS.DELETE_VACATION_DELEGATE, null, expect.anything()); + expect(Navigation.goBack).toHaveBeenCalledWith(ROUTES.SETTINGS_PROFILE.route); + }); + + it('surfaces the EXP_ERROR response message in the error modal and restores the previous delegate on dismissal', async () => { + // Given a response carrying a server-provided EXP_ERROR message, since a regression once dropped this message + // for the generic copy and this page owns the guarantee that it reaches the user + const EXP_ERROR_MESSAGE = 'This delegate has already been assigned as your submitsTo approver.'; + apiSideEffectSpy = jest + .spyOn(require('@libs/API'), 'makeRequestWithSideEffects') + .mockImplementation(() => Promise.resolve({jsonCode: CONST.JSON_CODE.EXP_ERROR, message: EXP_ERROR_MESSAGE})); + // jest.mock's factory functions (unlike jest.spyOn) are not reset by jest.restoreAllMocks() in afterEach, so call counts otherwise leak across tests in this file. + jest.mocked(Navigation.goBack).mockClear(); + + renderPage(); + await waitForBatchedUpdatesWithAct(); + + // When a delegate row is tapped and the request fails + fireEvent.press(screen.getByTestId('select-delegate-a')); + await waitForBatchedUpdatesWithAct(); + + // Then the server's own message reaches the error modal, the page does not navigate away, and no delegate or error is left behind + expect(mockShowConfirmModal).toHaveBeenCalledWith(expect.objectContaining({prompt: EXP_ERROR_MESSAGE})); + expect(Navigation.goBack).not.toHaveBeenCalled(); + + const vacationDelegate = await getOnyxValue(ONYXKEYS.NVP_PRIVATE_VACATION_DELEGATE); + expect(vacationDelegate?.delegate).toBeFalsy(); + expect(vacationDelegate?.errors).toBeFalsy(); + }); + + it('falls back to the generic error copy when the response carries no EXP_ERROR message', async () => { + // Given a failure response with no server-provided message, e.g. a non-EXP_ERROR failure or a transport rejection + apiSideEffectSpy = jest.spyOn(require('@libs/API'), 'makeRequestWithSideEffects').mockImplementation(() => Promise.resolve({jsonCode: CONST.JSON_CODE.BAD_REQUEST})); + + renderPage(); + await waitForBatchedUpdatesWithAct(); + + // When a delegate row is tapped and the request fails + fireEvent.press(screen.getByTestId('select-delegate-a')); + await waitForBatchedUpdatesWithAct(); + + // Then the generic translation is used instead of a blank or missing prompt + expect(mockShowConfirmModal).toHaveBeenCalledWith(expect.objectContaining({prompt: TestHelper.translateLocal('statusPage.vacationDelegateError')})); + }); + + it('ignores a second row selection while the first request is still pending', async () => { + // Given a request that never resolves, so the first pick stays "in flight" for the duration of the test + apiSideEffectSpy = jest.spyOn(require('@libs/API'), 'makeRequestWithSideEffects').mockImplementation(() => new Promise(() => {})); + + renderPage(); + await waitForBatchedUpdatesWithAct(); + + // When a second row is tapped before the first request settles + fireEvent.press(screen.getByTestId('select-delegate-a')); + fireEvent.press(screen.getByTestId('select-delegate-b')); + await waitForBatchedUpdatesWithAct(); + + // Then only the first pick is sent, so a fast double tap cannot fire two overlapping requests + expect(apiSideEffectSpy).toHaveBeenCalledTimes(1); + expect(apiSideEffectSpy).toHaveBeenCalledWith( + SIDE_EFFECT_REQUEST_COMMANDS.SET_VACATION_DELEGATE, + expect.objectContaining({vacationDelegateEmail: DELEGATE_A_EMAIL}), + expect.anything(), + ); + }); + + it('rolls back the optimistic delegate instead of leaving a stuck pending row when the request rejects', async () => { + // Given a request that rejects outright, simulating a transport failure (e.g. connection dropped after the tap) + // rather than a resolved response with a jsonCode + apiSideEffectSpy = jest.spyOn(require('@libs/API'), 'makeRequestWithSideEffects').mockImplementation(() => Promise.reject(new Error('Failed to fetch'))); + // jest.mock's factory functions (unlike jest.spyOn) are not reset by jest.restoreAllMocks() in afterEach, so call counts otherwise leak across tests in this file. + jest.mocked(Navigation.goBack).mockClear(); + + renderPage(); + await waitForBatchedUpdatesWithAct(); + + // When a delegate row is tapped and the request rejects + fireEvent.press(screen.getByTestId('select-delegate-a')); + await waitForBatchedUpdatesWithAct(); + + // Then the optimistic delegate and any error are rolled back instead of left stuck, and the page does not navigate away + expect(Navigation.goBack).not.toHaveBeenCalled(); + const vacationDelegate = await getOnyxValue(ONYXKEYS.NVP_PRIVATE_VACATION_DELEGATE); + expect(vacationDelegate?.pendingAction).toBeFalsy(); + expect(vacationDelegate?.delegate).toBeFalsy(); + expect(vacationDelegate?.errors).toBeFalsy(); + + // When the same row is tapped again after the failed request has settled + fireEvent.press(screen.getByTestId('select-delegate-a')); + await waitForBatchedUpdatesWithAct(); + + // Then the new tap is not ignored as "still pending", since the earlier request already settled + expect(apiSideEffectSpy).toHaveBeenCalledTimes(2); + }); + + it('rolls back to the last confirmed delegate, not to an unconfirmed one, when a previous change is still unresolved', async () => { + // Given a request that never resolves, so the optimistic write for the second selection stays in place for the + // assertion, and an NVP already left in the state a failed change the user has not dismissed leaves behind: delegateA shown but never saved + apiSideEffectSpy = jest.spyOn(require('@libs/API'), 'makeRequestWithSideEffects').mockImplementation(() => new Promise(() => {})); + await act(async () => { + await Onyx.set(ONYXKEYS.NVP_PRIVATE_VACATION_DELEGATE, { + creator: CREATOR_EMAIL, + delegate: DELEGATE_A_EMAIL, + previousDelegate: ORIGINAL_DELEGATE_EMAIL, + errors: getMicroSecondOnyxErrorWithTranslationKey('statusPage.vacationDelegateError'), + }); + }); + + renderPage(); + await waitForBatchedUpdatesWithAct(); + + // When a different delegate is picked + fireEvent.press(screen.getByTestId('select-delegate-b')); + await waitForBatchedUpdatesWithAct(); + + // Then the rollback target is the original confirmed delegate, not the unconfirmed delegateA; the API call is + // mocked out, so the optimistic data it was handed is where that target is visible + expect(apiSideEffectSpy).toHaveBeenLastCalledWith( + SIDE_EFFECT_REQUEST_COMMANDS.SET_VACATION_DELEGATE, + expect.objectContaining({vacationDelegateEmail: DELEGATE_B_EMAIL}), + expect.objectContaining({ + optimisticData: [expect.objectContaining({value: expect.objectContaining({delegate: DELEGATE_B_EMAIL, previousDelegate: ORIGINAL_DELEGATE_EMAIL})})], + }), + ); + }); + + it('rolls back the optimistic delegate instead of leaving an unconfirmed policy diff behind when the screen loses focus before the response resolves', async () => { + // Given a request whose resolution is held open, so the screen can navigate away before it settles + let resolveSideEffect: (response: {jsonCode: number; data?: {policyDiff: unknown}}) => void = () => {}; + apiSideEffectSpy = jest.spyOn(require('@libs/API'), 'makeRequestWithSideEffects').mockImplementation( + () => + new Promise((resolve) => { + resolveSideEffect = resolve; + }), + ); + jest.mocked(Navigation.navigate).mockClear(); + + renderPageWithStack(); + await waitForBatchedUpdatesWithAct(); + + fireEvent.press(screen.getByTestId('select-delegate-a')); + await waitForBatchedUpdatesWithAct(); + + // When the screen loses focus before the request settles, simulating the user swiping the RHP away (or otherwise navigating off this screen) + await act(async () => { + testNavigationRef.current?.navigate('Other'); + await waitForBatchedUpdatesWithAct(); + }); + + // When the request then resolves with a 305 policy diff warning + await act(async () => { + resolveSideEffect({jsonCode: CONST.JSON_CODE.POLICY_DIFF_WARNING, data: {policyDiff: {adminPolicies: [], nonAdminPolicies: []}}}); + await waitForBatchedUpdatesWithAct(); + }); + + // Then the missing-workspaces step is not pushed onto whatever the user navigated to instead, since there's no longer a + // screen to carry them into it, and the optimistic delegate/policy diff are rolled back rather than left unconfirmed + expect(Navigation.navigate).not.toHaveBeenCalledWith(ROUTES.SETTINGS_VACATION_DELEGATE_MISSING_WORKSPACES); + + const vacationDelegate = await getOnyxValue(ONYXKEYS.NVP_PRIVATE_VACATION_DELEGATE); + expect(vacationDelegate?.delegate).toBeFalsy(); + expect(vacationDelegate?.policyDiff).toBeFalsy(); + expect(vacationDelegate?.pendingAction).toBeFalsy(); + expect(vacationDelegate?.errors).toBeFalsy(); + }); +}); diff --git a/tests/ui/VacationDelegateMissingWorkspacesPageTest.tsx b/tests/ui/VacationDelegateMissingWorkspacesPageTest.tsx index a7ea242effcf..41600baf6015 100644 --- a/tests/ui/VacationDelegateMissingWorkspacesPageTest.tsx +++ b/tests/ui/VacationDelegateMissingWorkspacesPageTest.tsx @@ -534,6 +534,30 @@ describe('VacationDelegateMissingWorkspacesPage', () => { expect(rows.at(1)).toHaveTextContent('Zebra Workspace'); }); + it('sends the clear after datetime picked on the form again and returns to the profile page', async () => { + // Given a 305 that parked a pick made with a clear after date, since the form step is gone by the time this step saves it + const clearAfter = '2026-10-02 06:59:59'; + await seedVacationDelegate({adminPolicies: [], nonAdminPolicies: [MEMBER_POLICY_ID]}); + await act(async () => { + await Onyx.merge(ONYXKEYS.NVP_PRIVATE_VACATION_DELEGATE, {pendingClearAfter: clearAfter}); + }); + const goBackSpy = jest.spyOn(Navigation, 'goBack').mockImplementation(() => {}); + renderPage(); + await waitForBatchedUpdatesWithAct(); + + // When Confirm is pressed + fireEvent.press(screen.getByRole('button', {name: TestHelper.translateLocal('common.confirm')})); + await waitForBatchedUpdatesWithAct(); + + // Then the date is saved with the delegate instead of being dropped, and the user lands back on the profile page + expect(apiWriteSpy).toHaveBeenCalledWith( + WRITE_COMMANDS.SET_VACATION_DELEGATE, + expect.objectContaining({vacationDelegateEmail: DELEGATE_EMAIL, clearAfter, overridePolicyDiffWarning: true}), + expect.anything(), + ); + expect(goBackSpy).toHaveBeenCalledWith(ROUTES.SETTINGS_PROFILE.route); + }); + it('still asks the backend to email the non-admin workspaces when Skip is pressed on a mixed diff', async () => { // Given a mixed policy diff, since Skip only skips the invites the user controls: the owners of the // workspaces they don't administer are still emailed, matching Classic diff --git a/tests/ui/VacationDelegatePageTest.tsx b/tests/ui/VacationDelegatePageTest.tsx index 7faeaf2f7cfd..d73010af7feb 100644 --- a/tests/ui/VacationDelegatePageTest.tsx +++ b/tests/ui/VacationDelegatePageTest.tsx @@ -5,8 +5,6 @@ import {CurrentUserPersonalDetailsProvider} from '@components/CurrentUserPersona import {LocaleContextProvider} from '@components/LocaleContextProvider'; import OnyxListItemProvider from '@components/OnyxListItemProvider'; -import {SIDE_EFFECT_REQUEST_COMMANDS} from '@libs/API/types'; -import {getMicroSecondOnyxErrorWithTranslationKey} from '@libs/ErrorUtils'; import Navigation from '@libs/Navigation/Navigation'; import VacationDelegatePage from '@pages/settings/Profile/CustomStatus/VacationDelegatePage'; @@ -15,12 +13,10 @@ import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; -import type * as ReactNavigation from '@react-navigation/native'; // eslint-disable-next-line no-restricted-imports -- React Native Pressable/Text are required only to type the actual Jest module export; this does not import them at runtime. import type {Pressable as ReactNativePressable, Text as ReactNativeText} from 'react-native'; -import {createNavigationContainerRef, NavigationContainer} from '@react-navigation/native'; -import {createStackNavigator} from '@react-navigation/stack'; +import {NavigationContainer} from '@react-navigation/native'; import React from 'react'; import Onyx from 'react-native-onyx'; @@ -31,8 +27,6 @@ import waitForBatchedUpdatesWithAct from '../utils/waitForBatchedUpdatesWithAct' const CREATOR_ACCOUNT_ID = 1; const CREATOR_EMAIL = 'creator@example.com'; const DELEGATE_A_EMAIL = 'delegateA@example.com'; -const DELEGATE_B_EMAIL = 'delegateB@example.com'; -const ORIGINAL_DELEGATE_EMAIL = 'original@example.com'; jest.mock('@libs/Navigation/Navigation', () => ({ navigate: jest.fn(), @@ -42,39 +36,14 @@ jest.mock('@libs/Navigation/Navigation', () => ({ isNavigationReady: jest.fn(() => Promise.resolve()), })); -jest.mock('@react-navigation/native', () => { - const actualNavigation: typeof ReactNavigation = jest.requireActual('@react-navigation/native'); - - return { - ...actualNavigation, - useRoute: jest.fn(() => ({params: {}})), - usePreventRemove: jest.fn(), - }; -}); - -// The real confirm modal is bridged through the global modal system, which needs Navigation methods this file's -// lightweight Navigation mock doesn't provide. Stub the hook instead so tests can assert on what VacationDelegatePage -// asks it to show without pulling in that machinery. -const mockShowConfirmModal = jest.fn(); -jest.mock('@hooks/useConfirmModal', () => - jest.fn(() => ({ - showConfirmModal: mockShowConfirmModal, - })), -); - -// Replaces the real, personal-details-backed selection list with two plain pressable rows so tests can -// simulate rapid row taps on VacationDelegatePage's onSelectRow without driving the full list UI. +// Replaces the real, personal-details-backed selection list with a plain pressable row so tests can +// simulate a row tap on VacationDelegatePage's onSelectRow without driving the full list UI. jest.mock('@components/BaseVacationDelegateSelectionComponent', () => { const ReactMock = jest.requireActual('react'); const {Pressable, Text} = jest.requireActual<{Pressable: typeof ReactNativePressable; Text: typeof ReactNativeText}>('react-native'); return ({onSelectRow}: {onSelectRow: (option: {login: string}) => void}) => - ReactMock.createElement( - ReactMock.Fragment, - null, - ReactMock.createElement(Pressable, {testID: 'select-delegate-a', onPress: () => onSelectRow({login: 'delegateA@example.com'})}, ReactMock.createElement(Text, null, 'select-a')), - ReactMock.createElement(Pressable, {testID: 'select-delegate-b', onPress: () => onSelectRow({login: 'delegateB@example.com'})}, ReactMock.createElement(Text, null, 'select-b')), - ); + ReactMock.createElement(Pressable, {testID: 'select-delegate-a', onPress: () => onSelectRow({login: 'delegateA@example.com'})}, ReactMock.createElement(Text, null, 'select-a')); }); function renderPage() { @@ -87,43 +56,13 @@ function renderPage() { ); } -// A real Stack.Navigator so pushing "Other" genuinely blurs VacationDelegatePage's own useNavigation().isFocused(), -// unlike renderPage() above where the page is the sole, permanently-focused screen. -type TestParamList = { - VacationDelegate: undefined; - Other: undefined; -}; -const Stack = createStackNavigator(); -const testNavigationRef = createNavigationContainerRef(); - -function renderPageWithStack() { - return render( - - - - - null} - /> - - - , - ); -} - describe('VacationDelegatePage', () => { - let apiSideEffectSpy: jest.SpyInstance; - beforeAll(() => { Onyx.init({keys: ONYXKEYS}); }); beforeEach(async () => { - mockShowConfirmModal.mockReset().mockResolvedValue({action: 'CLOSE'}); + jest.mocked(Navigation.goBack).mockClear(); await act(async () => { await Onyx.set(ONYXKEYS.NVP_PREFERRED_LOCALE, CONST.LOCALES.EN); }); @@ -139,187 +78,41 @@ describe('VacationDelegatePage', () => { await waitForBatchedUpdatesWithAct(); }); - it('calls setVacationDelegate once for a single selection', async () => { - // Given a signed-in creator (see beforeEach) and a side-effect request mocked to succeed, so only what the - // page sends and does next is under test - apiSideEffectSpy = jest.spyOn(require('@libs/API'), 'makeRequestWithSideEffects').mockImplementation(() => Promise.resolve({jsonCode: CONST.JSON_CODE.SUCCESS})); + it('stores the picked member on the form instead of saving it', async () => { + // Given a picker with no saved delegate, and the API spied on so any save would be visible + const apiSideEffectSpy = jest.spyOn(require('@libs/API'), 'makeRequestWithSideEffects'); renderPage(); await waitForBatchedUpdatesWithAct(); - // When a delegate row is tapped + // When a member is picked fireEvent.press(screen.getByTestId('select-delegate-a')); await waitForBatchedUpdatesWithAct(); - // Then setVacationDelegate is sent exactly once with the mapped params, and the page navigates back to the status page - expect(apiSideEffectSpy).toHaveBeenCalledTimes(1); - expect(apiSideEffectSpy).toHaveBeenCalledWith( - SIDE_EFFECT_REQUEST_COMMANDS.SET_VACATION_DELEGATE, - expect.objectContaining({creator: CREATOR_EMAIL, vacationDelegateEmail: DELEGATE_A_EMAIL}), - expect.anything(), - ); - expect(Navigation.goBack).toHaveBeenCalledWith(ROUTES.SETTINGS_STATUS); + // Then nothing is saved yet, since the clear after date is chosen on the form, and the pick is handed to the form + expect(apiSideEffectSpy).not.toHaveBeenCalled(); + const draft = await getOnyxValue(ONYXKEYS.FORMS.VACATION_DELEGATE_FORM_DRAFT); + expect(draft?.delegate).toBe(DELEGATE_A_EMAIL); + expect(Navigation.goBack).toHaveBeenCalledWith(ROUTES.SETTINGS_VACATION_DELEGATE); }); - it('surfaces the EXP_ERROR response message in the error modal and restores the previous delegate on dismissal', async () => { - // Given a response carrying a server-provided EXP_ERROR message, since a regression once dropped this message - // for the generic copy and this page owns the guarantee that it reaches the user - const EXP_ERROR_MESSAGE = 'This delegate has already been assigned as your submitsTo approver.'; - apiSideEffectSpy = jest - .spyOn(require('@libs/API'), 'makeRequestWithSideEffects') - .mockImplementation(() => Promise.resolve({jsonCode: CONST.JSON_CODE.EXP_ERROR, message: EXP_ERROR_MESSAGE})); - // jest.mock's factory functions (unlike jest.spyOn) are not reset by jest.restoreAllMocks() in afterEach, so call counts otherwise leak across tests in this file. - jest.mocked(Navigation.goBack).mockClear(); - - renderPage(); - await waitForBatchedUpdatesWithAct(); - - // When a delegate row is tapped and the request fails - fireEvent.press(screen.getByTestId('select-delegate-a')); - await waitForBatchedUpdatesWithAct(); - - // Then the server's own message reaches the error modal, the page does not navigate away, and no delegate or error is left behind - expect(mockShowConfirmModal).toHaveBeenCalledWith(expect.objectContaining({prompt: EXP_ERROR_MESSAGE})); - expect(Navigation.goBack).not.toHaveBeenCalled(); - - const vacationDelegate = await getOnyxValue(ONYXKEYS.NVP_PRIVATE_VACATION_DELEGATE); - expect(vacationDelegate?.delegate).toBeFalsy(); - expect(vacationDelegate?.errors).toBeFalsy(); - }); - - it('falls back to the generic error copy when the response carries no EXP_ERROR message', async () => { - // Given a failure response with no server-provided message, e.g. a non-EXP_ERROR failure or a transport rejection - apiSideEffectSpy = jest.spyOn(require('@libs/API'), 'makeRequestWithSideEffects').mockImplementation(() => Promise.resolve({jsonCode: CONST.JSON_CODE.BAD_REQUEST})); - - renderPage(); - await waitForBatchedUpdatesWithAct(); - - // When a delegate row is tapped and the request fails - fireEvent.press(screen.getByTestId('select-delegate-a')); - await waitForBatchedUpdatesWithAct(); - - // Then the generic translation is used instead of a blank or missing prompt - expect(mockShowConfirmModal).toHaveBeenCalledWith(expect.objectContaining({prompt: TestHelper.translateLocal('statusPage.vacationDelegateError')})); - }); - - it('ignores a second row selection while the first request is still pending', async () => { - // Given a request that never resolves, so the first pick stays "in flight" for the duration of the test - apiSideEffectSpy = jest.spyOn(require('@libs/API'), 'makeRequestWithSideEffects').mockImplementation(() => new Promise(() => {})); - - renderPage(); - await waitForBatchedUpdatesWithAct(); - - // When a second row is tapped before the first request settles - fireEvent.press(screen.getByTestId('select-delegate-a')); - fireEvent.press(screen.getByTestId('select-delegate-b')); - await waitForBatchedUpdatesWithAct(); - - // Then only the first pick is sent, so a fast double tap cannot fire two overlapping requests - expect(apiSideEffectSpy).toHaveBeenCalledTimes(1); - expect(apiSideEffectSpy).toHaveBeenCalledWith( - SIDE_EFFECT_REQUEST_COMMANDS.SET_VACATION_DELEGATE, - expect.objectContaining({vacationDelegateEmail: DELEGATE_A_EMAIL}), - expect.anything(), - ); - }); - - it('rolls back the optimistic delegate instead of leaving a stuck pending row when the request rejects', async () => { - // Given a request that rejects outright, simulating a transport failure (e.g. connection dropped after the tap) - // rather than a resolved response with a jsonCode - apiSideEffectSpy = jest.spyOn(require('@libs/API'), 'makeRequestWithSideEffects').mockImplementation(() => Promise.reject(new Error('Failed to fetch'))); - // jest.mock's factory functions (unlike jest.spyOn) are not reset by jest.restoreAllMocks() in afterEach, so call counts otherwise leak across tests in this file. - jest.mocked(Navigation.goBack).mockClear(); - - renderPage(); - await waitForBatchedUpdatesWithAct(); - - // When a delegate row is tapped and the request rejects - fireEvent.press(screen.getByTestId('select-delegate-a')); - await waitForBatchedUpdatesWithAct(); - - // Then the optimistic delegate and any error are rolled back instead of left stuck, and the page does not navigate away - expect(Navigation.goBack).not.toHaveBeenCalled(); - const vacationDelegate = await getOnyxValue(ONYXKEYS.NVP_PRIVATE_VACATION_DELEGATE); - expect(vacationDelegate?.pendingAction).toBeFalsy(); - expect(vacationDelegate?.delegate).toBeFalsy(); - expect(vacationDelegate?.errors).toBeFalsy(); - - // When the same row is tapped again after the failed request has settled - fireEvent.press(screen.getByTestId('select-delegate-a')); - await waitForBatchedUpdatesWithAct(); - - // Then the new tap is not ignored as "still pending", since the earlier request already settled - expect(apiSideEffectSpy).toHaveBeenCalledTimes(2); - }); - - it('rolls back to the last confirmed delegate, not to an unconfirmed one, when a previous change is still unresolved', async () => { - // Given a request that never resolves, so the optimistic write for the second selection stays in place for the - // assertion, and an NVP already left in the state a failed change the user has not dismissed leaves behind: delegateA shown but never saved - apiSideEffectSpy = jest.spyOn(require('@libs/API'), 'makeRequestWithSideEffects').mockImplementation(() => new Promise(() => {})); + it('does not delete the saved delegate when the same member is picked again', async () => { + // Given the member being picked is already the saved delegate + const apiWriteSpy = jest.spyOn(require('@libs/API'), 'write'); await act(async () => { - await Onyx.set(ONYXKEYS.NVP_PRIVATE_VACATION_DELEGATE, { - creator: CREATOR_EMAIL, - delegate: DELEGATE_A_EMAIL, - previousDelegate: ORIGINAL_DELEGATE_EMAIL, - errors: getMicroSecondOnyxErrorWithTranslationKey('statusPage.vacationDelegateError'), - }); + await Onyx.set(ONYXKEYS.NVP_PRIVATE_VACATION_DELEGATE, {creator: CREATOR_EMAIL, delegate: DELEGATE_A_EMAIL}); }); renderPage(); await waitForBatchedUpdatesWithAct(); - // When a different delegate is picked - fireEvent.press(screen.getByTestId('select-delegate-b')); - await waitForBatchedUpdatesWithAct(); - - // Then the rollback target is the original confirmed delegate, not the unconfirmed delegateA; the API call is - // mocked out, so the optimistic data it was handed is where that target is visible - expect(apiSideEffectSpy).toHaveBeenLastCalledWith( - SIDE_EFFECT_REQUEST_COMMANDS.SET_VACATION_DELEGATE, - expect.objectContaining({vacationDelegateEmail: DELEGATE_B_EMAIL}), - expect.objectContaining({ - optimisticData: [expect.objectContaining({value: expect.objectContaining({delegate: DELEGATE_B_EMAIL, previousDelegate: ORIGINAL_DELEGATE_EMAIL})})], - }), - ); - }); - - it('rolls back the optimistic delegate instead of leaving an unconfirmed policy diff behind when the screen loses focus before the response resolves', async () => { - // Given a request whose resolution is held open, so the screen can navigate away before it settles - let resolveSideEffect: (response: {jsonCode: number; data?: {policyDiff: unknown}}) => void = () => {}; - apiSideEffectSpy = jest.spyOn(require('@libs/API'), 'makeRequestWithSideEffects').mockImplementation( - () => - new Promise((resolve) => { - resolveSideEffect = resolve; - }), - ); - jest.mocked(Navigation.navigate).mockClear(); - - renderPageWithStack(); - await waitForBatchedUpdatesWithAct(); - + // When that member is picked fireEvent.press(screen.getByTestId('select-delegate-a')); await waitForBatchedUpdatesWithAct(); - // When the screen loses focus before the request settles, simulating the user swiping the RHP away (or otherwise navigating off this screen) - await act(async () => { - testNavigationRef.current?.navigate('Other'); - await waitForBatchedUpdatesWithAct(); - }); - - // When the request then resolves with a 305 policy diff warning - await act(async () => { - resolveSideEffect({jsonCode: CONST.JSON_CODE.POLICY_DIFF_WARNING, data: {policyDiff: {adminPolicies: [], nonAdminPolicies: []}}}); - await waitForBatchedUpdatesWithAct(); - }); - - // Then the missing-workspaces step is not pushed onto whatever the user navigated to instead, since there's no longer a - // screen to carry them into it, and the optimistic delegate/policy diff are rolled back rather than left unconfirmed - expect(Navigation.navigate).not.toHaveBeenCalledWith(ROUTES.SETTINGS_VACATION_DELEGATE_MISSING_WORKSPACES); - + // Then the delegate is kept, since removing it is now the form's remove button, not a quiet side effect of re-picking + expect(apiWriteSpy).not.toHaveBeenCalled(); const vacationDelegate = await getOnyxValue(ONYXKEYS.NVP_PRIVATE_VACATION_DELEGATE); - expect(vacationDelegate?.delegate).toBeFalsy(); - expect(vacationDelegate?.policyDiff).toBeFalsy(); - expect(vacationDelegate?.pendingAction).toBeFalsy(); - expect(vacationDelegate?.errors).toBeFalsy(); + expect(vacationDelegate?.delegate).toBe(DELEGATE_A_EMAIL); }); }); diff --git a/tests/unit/components/VacationDelegateMenuItemTest.tsx b/tests/unit/components/VacationDelegateMenuItemTest.tsx index 76da533e9261..6240ecabe43a 100644 --- a/tests/unit/components/VacationDelegateMenuItemTest.tsx +++ b/tests/unit/components/VacationDelegateMenuItemTest.tsx @@ -246,4 +246,37 @@ describe('VacationDelegateMenuItem', () => { // Then the empty state shows at once instead of a blank field for the duration of the request expect(screen.getByText('common.vacationDelegate')).toBeTruthy(); }); + + it('shows the empty state for a delegate whose clear after datetime has passed', () => { + // Given a delegate whose clear after datetime is in the past, before the backend's clear has reached the client + mockUseVacationDelegatePersonalDetails.mockReturnValue({accountID: 42, login: EMAIL_DELEGATE, displayName: 'Jane Doe'}); + + // When the menu item renders it + render( + , + ); + + // Then it is not shown as an active delegate, since approvals no longer go to them + expect(screen.queryByText('Jane Doe')).toBeNull(); + expect(screen.getByText('common.vacationDelegate')).toBeTruthy(); + }); + + it('shows when the delegate clears', () => { + // Given a delegate that clears in the future + mockUseVacationDelegatePersonalDetails.mockReturnValue({accountID: 42, login: EMAIL_DELEGATE, displayName: 'Jane Doe'}); + + // When the menu item renders it + render( + , + ); + + // Then the "until" line is shown under the login, so the vacationer and admins can see when it ends + expect(screen.getByText(`${EMAIL_DELEGATE}\nstatusPage.vacationDelegate.until`)).toBeTruthy(); + }); }); diff --git a/tests/unit/libs/VacationDelegateUtilsTest.ts b/tests/unit/libs/VacationDelegateUtilsTest.ts new file mode 100644 index 000000000000..6f0a2bb31e7c --- /dev/null +++ b/tests/unit/libs/VacationDelegateUtilsTest.ts @@ -0,0 +1,58 @@ +import {formatVacationDelegateClearDate, getVacationDelegateClearAfter, getVacationDelegateClearDate, isVacationDelegateExpired} from '@libs/VacationDelegateUtils'; + +describe('VacationDelegateUtils', () => { + beforeEach(() => { + jest.useFakeTimers(); + jest.setSystemTime(new Date('2026-09-28T12:00:00Z')); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + describe('getVacationDelegateClearAfter', () => { + it('converts the picked day to the UTC end of that day in the given timezone', () => { + // Given a day picked in Los Angeles, where Oct 1 ends at 06:59:59 UTC on Oct 2 during daylight saving time + // When it is converted for the backend + // Then the delegate stays active for the whole picked day, not just until midnight UTC + expect(getVacationDelegateClearAfter('2026-10-01', 'America/Los_Angeles')).toBe('2026-10-02 06:59:59'); + }); + + it('returns undefined when no day is picked, so the delegate never clears', () => { + // Given no picked day + // When it is converted for the backend + // Then nothing is sent, since the date is optional + expect(getVacationDelegateClearAfter('', 'America/Los_Angeles')).toBeUndefined(); + }); + }); + + describe('getVacationDelegateClearDate', () => { + it('converts the UTC clear after datetime back into the picked day', () => { + // Given the datetime the backend stored for a day picked in Los Angeles + // When it is read back in the same timezone + // Then it shows the day that was picked, not the next UTC day + expect(getVacationDelegateClearDate('2026-10-02 06:59:59', 'America/Los_Angeles')).toBe('2026-10-01'); + }); + }); + + describe('formatVacationDelegateClearDate', () => { + it('formats the day for display and ignores an invalid one', () => { + // Given a valid and an invalid day + // When they are formatted + // Then the valid one reads as a short date and the invalid one shows nothing instead of throwing + expect(formatVacationDelegateClearDate('2026-10-01', undefined)).toBe('Oct 1, 2026'); + expect(formatVacationDelegateClearDate('not a date', undefined)).toBe(''); + }); + }); + + describe('isVacationDelegateExpired', () => { + it('is true only once the clear after datetime has passed', () => { + // Given the current time is 2026-09-28 12:00 UTC + // When a time before it, a time after it, and no time are checked + // Then only the past one counts as expired, so a delegate that never clears stays active + expect(isVacationDelegateExpired('2026-09-28 11:59:59')).toBe(true); + expect(isVacationDelegateExpired('2026-09-28 12:00:01')).toBe(false); + expect(isVacationDelegateExpired(undefined)).toBe(false); + }); + }); +}); From 5cffbfc934a38bd9d5c17b8dee63c7c893d53666 Mon Sep 17 00:00:00 2001 From: "Kevin Brian Bader (via MelvinBot)" Date: Tue, 29 Sep 2026 01:08:27 +0000 Subject: [PATCH 02/12] Match the vacation delegate mockups: show the delegate as a pill, add a Choose delegate label, and keep the empty date label inside the input Co-authored-by: Kevin Brian Bader --- src/components/DatePicker/index.tsx | 3 +- src/components/DatePicker/types.ts | 7 ++ src/components/VacationDelegateForm.tsx | 2 + src/components/VacationDelegateMenuItem.tsx | 106 +++++++++++++----- src/languages/de.ts | 3 +- src/languages/el.ts | 3 +- src/languages/en.ts | 3 +- src/languages/es.ts | 3 +- src/languages/fr.ts | 3 +- src/languages/it.ts | 3 +- src/languages/ja.ts | 3 +- src/languages/nl.ts | 3 +- src/languages/pl.ts | 3 +- src/languages/pt-BR.ts | 3 +- src/languages/zh-hans.ts | 3 +- src/pages/settings/Profile/ProfilePage.tsx | 52 ++++++--- tests/ui/ProfilePageTest.tsx | 3 +- .../VacationDelegateMenuItemTest.tsx | 38 +++---- 18 files changed, 170 insertions(+), 74 deletions(-) diff --git a/src/components/DatePicker/index.tsx b/src/components/DatePicker/index.tsx index c228d91bd00f..feb75af8775c 100644 --- a/src/components/DatePicker/index.tsx +++ b/src/components/DatePicker/index.tsx @@ -52,6 +52,7 @@ function DatePicker({ rightHandSideComponent, onPickerVisibilityChange, shouldHideCalendarIcon = false, + shouldForceActiveLabel = true, }: DateInputWithPickerProps) { const icons = useMemoizedLazyExpensifyIcons(['Calendar']); const styles = useThemeStyles(); @@ -233,7 +234,7 @@ function DatePicker({ {description}} {!!formattedClearDate && {translate('statusPage.vacationDelegate.willClearOn', formattedClearDate)}} diff --git a/src/components/VacationDelegateMenuItem.tsx b/src/components/VacationDelegateMenuItem.tsx index 86f17470a16f..7440edb40139 100644 --- a/src/components/VacationDelegateMenuItem.tsx +++ b/src/components/VacationDelegateMenuItem.tsx @@ -5,23 +5,82 @@ import useThemeStyles from '@hooks/useThemeStyles'; import useVacationDelegatePersonalDetails from '@hooks/useVacationDelegatePersonalDetails'; import getVacationDelegateDisplayName from '@libs/getVacationDelegateDisplayName'; +import type {AvatarSource} from '@libs/UserAvatarUtils'; import {formatVacationDelegateClearDate, getVacationDelegateClearDate, isVacationDelegateExpired} from '@libs/VacationDelegateUtils'; -import CONST from '@src/CONST'; +import {callFunctionIfActionIsAllowed} from '@userActions/Session'; + +import type CONST from '@src/CONST'; import type {Errors, PendingAction} from '@src/types/onyx/OnyxCommon'; import type {BaseVacationDelegate} from '@src/types/onyx/VacationDelegate'; +import type {ValueOf} from 'type-fest'; + import React from 'react'; -import UserAvatar from './Avatar/UserAvatar'; import MenuItem from './MenuItem'; import MenuItemField from './MenuItem/presets/MenuItemField'; -import MenuItemWithLabel from './MenuItem/presets/MenuItemWithLabel'; import OfflineWithFeedback from './OfflineWithFeedback'; +import UserPill from './UserPill'; + +type VacationDelegateMenuItemRowProps = { + /** Text above the delegate, naming what the row holds */ + label: string; + + /** Name shown in the delegate's pill */ + displayName: string; + + /** Avatar shown in the delegate's pill */ + avatar?: AvatarSource; + + /** Account ID of the delegate */ + accountID?: number; + + /** Login of the delegate */ + login?: string; + + /** Text under the pill saying when the delegate clears */ + untilText?: string; + + /** Indicator shown next to the chevron */ + brickRoadIndicator?: ValueOf; +}; + +/** + * The line of a set vacation delegate, without a `MenuItem.Root` of its own: the label, the delegate as a pill, and when it clears. + * The pill is not a text leaf, so the `Root` around it should get an `accessibilityLabel` that names the delegate. + */ +function VacationDelegateMenuItemRow({label, displayName, avatar, accountID, login, untilText, brickRoadIndicator}: VacationDelegateMenuItemRowProps) { + const styles = useThemeStyles(); + const icons = useMemoizedLazyExpensifyIcons(['FallbackAvatar']); + + return ( + + + {label} + + {!!untilText && {untilText}} + + + {!!brickRoadIndicator && } + + + + ); +} type VacationDelegateSectionProps = { vacationDelegate?: BaseVacationDelegate; + /** Text above the delegate. Defaults to "Vacation delegate" */ + label?: string; + /** Errors related to setting the vacation delegate */ errors?: Errors; @@ -40,12 +99,12 @@ type VacationDelegateSectionProps = { onPress: () => void; }; -function VacationDelegateMenuItem({vacationDelegate, errors, pendingAction, onCloseError, onPress}: VacationDelegateSectionProps) { +function VacationDelegateMenuItemPreset({vacationDelegate, label, errors, pendingAction, onCloseError, onPress}: VacationDelegateSectionProps) { const styles = useThemeStyles(); const {translate, formatPhoneNumber, dateFnsLocale} = useLocalize(); - const icons = useMemoizedLazyExpensifyIcons(['FallbackAvatar']); const {timezone} = useCurrentUserPersonalDetails(); + const rowLabel = label ?? translate('common.vacationDelegate'); const hasVacationDelegate = !!vacationDelegate?.delegate && !isVacationDelegateExpired(vacationDelegate?.clearAfter); const vacationDelegatePersonalDetails = useVacationDelegatePersonalDetails(vacationDelegate?.delegate); @@ -53,7 +112,6 @@ function VacationDelegateMenuItem({vacationDelegate, errors, pendingAction, onCl const delegateDisplayName = getVacationDelegateDisplayName(rawDelegateLogin, vacationDelegatePersonalDetails?.displayName, formatPhoneNumber); const clearDate = formatVacationDelegateClearDate(getVacationDelegateClearDate(vacationDelegate?.clearAfter, timezone?.selected), dateFnsLocale); const untilText = clearDate ? translate('statusPage.vacationDelegate.until', clearDate) : ''; - const delegateDescription = [formatPhoneNumber(rawDelegateLogin), untilText].filter(Boolean).join('\n'); return ( {hasVacationDelegate ? ( - - - - - - - {delegateDisplayName} - {!!delegateDescription && {delegateDescription}} - - - - - - + + ) : ( )} @@ -95,4 +145,6 @@ function VacationDelegateMenuItem({vacationDelegate, errors, pendingAction, onCl ); } +const VacationDelegateMenuItem = Object.assign(VacationDelegateMenuItemPreset, {Row: VacationDelegateMenuItemRow}); + export default VacationDelegateMenuItem; diff --git a/src/languages/de.ts b/src/languages/de.ts index a4190759f992..437caf4bae5c 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -3861,8 +3861,9 @@ ${amount} für ${merchant} – ${date}`, `${delegate} ist kein Mitglied der unten aufgeführten Arbeitsbereiche. Admins der Arbeitsbereiche, die Sie nicht verwalten, werden gebeten, die Person hinzuzufügen. Möchten Sie die Person jetzt zu den Arbeitsbereichen einladen, in denen Sie Admin sind?`, youAreAMemberOf: 'Sie sind Mitglied dieser Arbeitsbereiche:', youAreAnAdminOf: 'Sie sind Admin dieser Arbeitsbereiche:', + chooseDelegate: 'Vertretung auswählen', clearAfterRecommended: 'Löschen nach (empfohlen)', - willClearOn: (date: string) => `Die Urlaubsvertretung wird am ${date} entfernt.`, + willClearOn: (date: string) => `Die Urlaubsvertretung wird am ${date} entfernt`, until: (date: string) => `Bis ${date}`, removeDelegate: 'Urlaubsvertretung entfernen', }, diff --git a/src/languages/el.ts b/src/languages/el.ts index c0df52507877..7dc64aeebc47 100644 --- a/src/languages/el.ts +++ b/src/languages/el.ts @@ -3911,8 +3911,9 @@ ${amount} για ${merchant} - ${date}`, `Ο/Η ${delegate} δεν είναι μέλος των παρακάτω χώρων εργασίας. Θα ζητηθεί από τους διαχειριστές των χώρων εργασίας που δεν διαχειρίζεστε να τον/την προσθέσουν. Θέλετε να τον/την προσκαλέσετε τώρα σε αυτούς που διαχειρίζεστε ως διαχειριστής;`, youAreAMemberOf: 'Είστε μέλος αυτών των χώρων εργασίας:', youAreAnAdminOf: 'Είστε διαχειριστής αυτών των χώρων εργασίας:', + chooseDelegate: 'Επιλέξτε αναπληρωτή', clearAfterRecommended: 'Εκκαθάριση μετά από (συνιστάται)', - willClearOn: (date: string) => `Ο εκπρόσωπος διακοπών θα καταργηθεί στις ${date}.`, + willClearOn: (date: string) => `Ο εκπρόσωπος διακοπών θα καταργηθεί στις ${date}`, until: (date: string) => `Έως ${date}`, removeDelegate: 'Κατάργηση εκπροσώπου διακοπών', }, diff --git a/src/languages/en.ts b/src/languages/en.ts index 6da3472e8c4f..4bb0b6720bbf 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -3973,8 +3973,9 @@ const translations = { `${delegate} isn't a member of the workspaces below. Admins of the workspaces you don't manage will be asked to add them. Do you want to invite them to the ones you are an admin of now?`, youAreAMemberOf: 'You are a member of these workspaces:', youAreAnAdminOf: 'You are an admin of these workspaces:', + chooseDelegate: 'Choose delegate', clearAfterRecommended: 'Clear after (recommended)', - willClearOn: (date: string) => `Vacation delegate will clear on ${date}.`, + willClearOn: (date: string) => `Vacation delegate will clear on ${date}`, until: (date: string) => `Until ${date}`, removeDelegate: 'Remove vacation delegate', }, diff --git a/src/languages/es.ts b/src/languages/es.ts index 1379743076d0..65835143e38a 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -3779,8 +3779,9 @@ ${amount} para ${merchant} - ${date}`, `${delegate} no es miembro de los siguientes espacios de trabajo. Se pedirá a los administradores de los espacios de trabajo que no gestiona que lo agreguen. ¿Desea invitarlo ahora a los espacios de trabajo en los que es administrador?`, youAreAMemberOf: 'Usted es miembro de estos espacios de trabajo:', youAreAnAdminOf: 'Usted es administrador de estos espacios de trabajo:', + chooseDelegate: 'Elegir delegado', clearAfterRecommended: 'Borrar después de (recomendado)', - willClearOn: (date: string) => `El delegado de vacaciones se eliminará el ${date}.`, + willClearOn: (date: string) => `El delegado de vacaciones se eliminará el ${date}`, until: (date: string) => `Hasta el ${date}`, removeDelegate: 'Eliminar delegado de vacaciones', }, diff --git a/src/languages/fr.ts b/src/languages/fr.ts index 263ca6e4933c..0c701d9ee741 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -3869,8 +3869,9 @@ ${amount} pour ${merchant} - ${date}`, `${delegate} n'est pas membre des espaces de travail ci-dessous. Les administrateurs des espaces de travail que vous ne gérez pas seront invités à l'ajouter. Voulez-vous l'inviter dès maintenant à ceux dont vous êtes administrateur ?`, youAreAMemberOf: 'Vous êtes membre de ces espaces de travail :', youAreAnAdminOf: 'Vous êtes administrateur de ces espaces de travail :', + chooseDelegate: 'Choisir un délégué', clearAfterRecommended: 'Effacer après (recommandé)', - willClearOn: (date: string) => `Le délégué de vacances sera supprimé le ${date}.`, + willClearOn: (date: string) => `Le délégué de vacances sera supprimé le ${date}`, until: (date: string) => `Jusqu’au ${date}`, removeDelegate: 'Supprimer le délégué de vacances', }, diff --git a/src/languages/it.ts b/src/languages/it.ts index 37d6575ebf97..481e794fa8c2 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -3842,8 +3842,9 @@ ${amount} per ${merchant} - ${date}`, `${delegate} non è membro degli spazi di lavoro elencati di seguito. Agli amministratori degli spazi di lavoro che non gestisci verrà chiesto di aggiungerlo. Vuoi invitarlo ora in quelli di cui sei amministratore?`, youAreAMemberOf: 'Sei membro di questi spazi di lavoro:', youAreAnAdminOf: 'Sei amministratore di questi spazi di lavoro:', + chooseDelegate: 'Scegli delegato', clearAfterRecommended: 'Cancella dopo (consigliato)', - willClearOn: (date: string) => `La delega ferie verrà rimossa il ${date}.`, + willClearOn: (date: string) => `La delega ferie verrà rimossa il ${date}`, until: (date: string) => `Fino al ${date}`, removeDelegate: 'Rimuovi delega ferie', }, diff --git a/src/languages/ja.ts b/src/languages/ja.ts index 335360623851..23e2672fb73e 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -3811,8 +3811,9 @@ ${integrationName === CONST.ONBOARDING_ACCOUNTING_MAPPING.other ? 'あなたの' `${delegate}は以下のワークスペースのメンバーではありません。あなたが管理していないワークスペースの管理者に追加を依頼します。あなたが管理者になっているワークスペースには今すぐ招待しますか?`, youAreAMemberOf: 'あなたはこれらのワークスペースのメンバーです:', youAreAnAdminOf: 'あなたはこれらのワークスペースの管理者です:', + chooseDelegate: '代理人を選択', clearAfterRecommended: 'クリア日(推奨)', - willClearOn: (date: string) => `休暇代理人は${date}に解除されます。`, + willClearOn: (date: string) => `休暇代理人は${date}に解除されます`, until: (date: string) => `${date}まで`, removeDelegate: '休暇代理人を削除', }, diff --git a/src/languages/nl.ts b/src/languages/nl.ts index 682476d39dde..cc775dc8a743 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -3844,8 +3844,9 @@ ${amount} voor ${merchant} - ${date}`, `${delegate} is geen lid van de onderstaande werkruimtes. Beheerders van de werkruimtes die je niet beheert, wordt gevraagd diegene toe te voegen. Wil je diegene nu uitnodigen voor de werkruimtes waar je beheerder van bent?`, youAreAMemberOf: 'Je bent lid van deze werkruimtes:', youAreAnAdminOf: 'Je bent beheerder van deze werkruimtes:', + chooseDelegate: 'Gedelegeerde kiezen', clearAfterRecommended: 'Wissen na (aanbevolen)', - willClearOn: (date: string) => `De vertegenwoordiger tijdens vakantie wordt gewist op ${date}.`, + willClearOn: (date: string) => `De vertegenwoordiger tijdens vakantie wordt gewist op ${date}`, until: (date: string) => `Tot ${date}`, removeDelegate: 'Vertegenwoordiger tijdens vakantie verwijderen', }, diff --git a/src/languages/pl.ts b/src/languages/pl.ts index 057cfe15a4be..84575180ea28 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -3873,8 +3873,9 @@ ${amount} dla ${merchant} - ${date}`, `${delegate} nie jest członkiem poniższych przestrzeni roboczych. Administratorzy przestrzeni roboczych, którymi nie zarządzasz, zostaną poproszeni o dodanie tej osoby. Czy chcesz zaprosić tę osobę do przestrzeni, w których jesteś administratorem?`, youAreAMemberOf: 'Jesteś członkiem tych przestrzeni roboczych:', youAreAnAdminOf: 'Jesteś administratorem tych przestrzeni roboczych:', + chooseDelegate: 'Wybierz zastępcę', clearAfterRecommended: 'Wyczyść po (zalecane)', - willClearOn: (date: string) => `Zastępca urlopowy zostanie usunięty ${date}.`, + willClearOn: (date: string) => `Zastępca urlopowy zostanie usunięty ${date}`, until: (date: string) => `Do ${date}`, removeDelegate: 'Usuń zastępcę urlopowego', }, diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index d69f8dc7e152..0e02130d8f5a 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -3831,8 +3831,9 @@ ${amount} para ${merchant} - ${date}`, `${delegate} não é membro dos workspaces abaixo. Os admins dos workspaces que você não gerencia serão solicitados a adicioná-lo(a). Deseja convidá-lo(a) agora para os workspaces em que você é admin?`, youAreAMemberOf: 'Você é membro destes workspaces:', youAreAnAdminOf: 'Você é admin destes workspaces:', + chooseDelegate: 'Escolher delegado', clearAfterRecommended: 'Limpar após (recomendado)', - willClearOn: (date: string) => `O delegado de férias será removido em ${date}.`, + willClearOn: (date: string) => `O delegado de férias será removido em ${date}`, until: (date: string) => `Até ${date}`, removeDelegate: 'Remover delegado de férias', }, diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index 419603777b44..c99e62f254cb 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -3719,8 +3719,9 @@ ${amount},商户:${merchant} - 日期:${date}`, `${delegate} 不是以下工作区的成员。系统会请求你未管理的工作区的管理员将其添加进来。你要现在邀请他们加入你管理的工作区吗?`, youAreAMemberOf: '你是这些工作区的成员:', youAreAnAdminOf: '你是这些工作区的管理员:', + chooseDelegate: '选择代理人', clearAfterRecommended: '清除日期(推荐)', - willClearOn: (date: string) => `休假代理将于 ${date} 清除。`, + willClearOn: (date: string) => `休假代理将于 ${date} 清除`, until: (date: string) => `直到 ${date}`, removeDelegate: '移除休假代理', }, diff --git a/src/pages/settings/Profile/ProfilePage.tsx b/src/pages/settings/Profile/ProfilePage.tsx index a8b3e03db4d3..18bb2b46a7aa 100755 --- a/src/pages/settings/Profile/ProfilePage.tsx +++ b/src/pages/settings/Profile/ProfilePage.tsx @@ -15,6 +15,7 @@ import OfflineWithFeedback from '@components/OfflineWithFeedback'; import ScreenWrapper from '@components/ScreenWrapper'; import ScrollView from '@components/ScrollView'; import Section from '@components/Section'; +import VacationDelegateMenuItem from '@components/VacationDelegateMenuItem'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import useDocumentTitle from '@hooks/useDocumentTitle'; @@ -55,7 +56,7 @@ import type SCREENS from '@src/SCREENS'; import INPUT_IDS from '@src/types/form/PersonalDetailsForm'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; -import type {ComponentRef} from 'react'; +import type {ComponentRef, ReactNode} from 'react'; // eslint-disable-next-line no-restricted-imports import type {ScrollView as RNScrollView} from 'react-native'; import type {ValueOf} from 'type-fest'; @@ -111,6 +112,8 @@ function ProfilePage() { const vacationDelegateClearDate = hasVacationDelegate ? formatVacationDelegateClearDate(getVacationDelegateClearDate(vacationDelegate?.clearAfter, currentUserPersonalDetails?.timezone?.selected), dateFnsLocale) : ''; + const vacationDelegateUntilText = vacationDelegateClearDate ? translate('statusPage.vacationDelegate.until', vacationDelegateClearDate) : ''; + const vacationDelegateBrickRoadIndicator = isEmptyObject(getVacationDelegateErrors(vacationDelegate)) ? undefined : CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR; const {isActingAsDelegate} = useDelegateNoAccessState(); const {showDelegateNoAccessModal} = useDelegateNoAccessActions(); const publicOptions: Array<{ @@ -121,6 +124,12 @@ function ProfilePage() { brickRoadIndicator?: ValueOf; testID?: string; sentryLabel?: string; + + /** Replaces the default name and value line, for a value that is not plain text */ + content?: ReactNode; + + /** Accessibility label for a row whose `content` is not plain text */ + accessibilityLabel?: string; }> = [ { description: translate('displayNamePage.headerTitle'), @@ -149,14 +158,26 @@ function ProfilePage() { }, { description: translate('common.vacationDelegate'), - title: vacationDelegateClearDate ? `${vacationDelegateName} · ${translate('statusPage.vacationDelegate.until', vacationDelegateClearDate)}` : vacationDelegateName, + title: vacationDelegateName, + content: hasVacationDelegate ? ( + + ) : undefined, + accessibilityLabel: hasVacationDelegate ? [translate('common.vacationDelegate'), vacationDelegateName, vacationDelegateUntilText].filter(Boolean).join(', ') : undefined, // With no delegate there is nothing to show on the form yet, so go straight to picking one. pageRoute: hasVacationDelegate ? ROUTES.SETTINGS_VACATION_DELEGATE : ROUTES.SETTINGS_VACATION_DELEGATE_SELECT, onPress: () => { clearDraftValues(ONYXKEYS.FORMS.VACATION_DELEGATE_FORM); Navigation.navigate(hasVacationDelegate ? ROUTES.SETTINGS_VACATION_DELEGATE : ROUTES.SETTINGS_VACATION_DELEGATE_SELECT); }, - brickRoadIndicator: isEmptyObject(getVacationDelegateErrors(vacationDelegate)) ? undefined : CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR, + brickRoadIndicator: vacationDelegateBrickRoadIndicator, testID: 'vacation-delegate-menu-item', sentryLabel: CONST.SENTRY_LABEL.SETTINGS_PROFILE.VACATION_DELEGATE, }, @@ -307,18 +328,21 @@ function ProfilePage() { onPress={detail.onPress ?? (pageRoute ? () => Navigation.navigate(pageRoute) : undefined)} testID={detail?.testID} sentryLabel={detail.sentryLabel} + accessibilityLabel={detail.accessibilityLabel} > - - {(!!detail.brickRoadIndicator || !!pageRoute) && ( - <> - {!!detail.brickRoadIndicator && } - {!!pageRoute && } - - )} - + {detail.content ?? ( + + {(!!detail.brickRoadIndicator || !!pageRoute) && ( + <> + {!!detail.brickRoadIndicator && } + {!!pageRoute && } + + )} + + )} ); })} diff --git a/tests/ui/ProfilePageTest.tsx b/tests/ui/ProfilePageTest.tsx index 6c0b22138a22..ba74d7c8f7e9 100644 --- a/tests/ui/ProfilePageTest.tsx +++ b/tests/ui/ProfilePageTest.tsx @@ -352,7 +352,8 @@ describe('ProfilePage - agent account', () => { await waitForBatchedUpdatesWithAct(); // Then the row shows the delegate and the end date, so the user can see reports are routed elsewhere - expect(screen.getByText(/delegate@expensify\.com · Until/)).toBeDefined(); + expect(screen.getByText('delegate@expensify.com')).toBeDefined(); + expect(screen.getByText(/^Until /)).toBeDefined(); // When the row is pressed fireEvent.press(screen.getByTestId('vacation-delegate-menu-item'), {nativeEvent: {}}); diff --git a/tests/unit/components/VacationDelegateMenuItemTest.tsx b/tests/unit/components/VacationDelegateMenuItemTest.tsx index 6240ecabe43a..eb4c118a4b86 100644 --- a/tests/unit/components/VacationDelegateMenuItemTest.tsx +++ b/tests/unit/components/VacationDelegateMenuItemTest.tsx @@ -109,9 +109,8 @@ describe('VacationDelegateMenuItem', () => { // When the menu item renders that delegate renderMenuItem(EMAIL_DELEGATE); - // Then the name, email, and avatar all show as-is, since an email login has no SMS artifacts to strip + // Then the pill shows the name and avatar as-is, since an email login has no SMS artifacts to strip expect(screen.getByText('Jane Doe')).toBeTruthy(); - expect(screen.getByText(EMAIL_DELEGATE)).toBeTruthy(); expect(lastAvatarProps().accountID).toBe(42); expect(screen.queryByText(/@expensify\.sms/)).toBeNull(); }); @@ -129,8 +128,8 @@ describe('VacationDelegateMenuItem', () => { // When the menu item renders that delegate renderMenuItem(PHONE_DELEGATE_WITH_SMS_DOMAIN); - // Then the title is formatted into the localized number rather than shown as the raw E.164 login it is - expect(textOccurrences(PHONE_DELEGATE_LOCALIZED)).toBe(2); + // Then the pill is formatted into the localized number rather than shown as the raw E.164 login it is + expect(textOccurrences(PHONE_DELEGATE_LOCALIZED)).toBe(1); expect(screen.queryByText(PHONE_DELEGATE_RAW)).toBeNull(); expect(lastAvatarProps().accountID).toBe(43); expect(screen.queryByText(/@expensify\.sms/)).toBeNull(); @@ -149,13 +148,13 @@ describe('VacationDelegateMenuItem', () => { // When the menu item renders that delegate renderMenuItem(PHONE_DELEGATE_WITH_SMS_DOMAIN); - // Then the title is still localized instead of reaching the screen as a raw E.164 number, which is + // Then the pill is still localized instead of reaching the screen as a raw E.164 number, which is // what used to happen because stripping the domain alone isn't the same as detecting a phone number - expect(textOccurrences(PHONE_DELEGATE_LOCALIZED)).toBe(2); + expect(textOccurrences(PHONE_DELEGATE_LOCALIZED)).toBe(1); expect(screen.queryByText(PHONE_DELEGATE_RAW)).toBeNull(); }); - it('keeps the name a phone-number account did set, and localizes the number below it', () => { + it('keeps the name a phone-number account did set', () => { // Given a phone-number account that did set a real display name of its own const personalDetails: PersonalDetails = { accountID: 44, @@ -167,10 +166,10 @@ describe('VacationDelegateMenuItem', () => { // When the menu item renders that delegate renderMenuItem(PHONE_DELEGATE_WITH_SMS_DOMAIN); - // Then the real name is left untouched and only the number below it is localized, so formatting logic - // built for the no-name case doesn't clobber a name the account actually has + // Then the pill shows the real name untouched, so formatting logic built for the no-name case doesn't + // clobber a name the account actually has expect(screen.getByText('Jane Doe')).toBeTruthy(); - expect(textOccurrences(PHONE_DELEGATE_LOCALIZED)).toBe(1); + expect(screen.queryByText(PHONE_DELEGATE_LOCALIZED)).toBeNull(); expect(screen.queryByText(PHONE_DELEGATE_RAW)).toBeNull(); }); @@ -187,16 +186,14 @@ describe('VacationDelegateMenuItem', () => { // When the menu item renders that delegate renderMenuItem('9789942470@expensify.sms'); - // Then the title is localized rather than left as the raw E.164 display name, and the login below it - // still shows in its stored national form + // Then the pill is localized rather than left as the raw E.164 display name expect(screen.getByText(PHONE_DELEGATE_LOCALIZED)).toBeTruthy(); - expect(screen.getByText('9789942470')).toBeTruthy(); expect(screen.queryByText(PHONE_DELEGATE_RAW)).toBeNull(); }); }); describe('new account (personal details missing, e.g. after cache clear)', () => { - it('renders the raw email as title and description when no personal details exist', () => { + it('renders the raw email in the pill when no personal details exist', () => { // Given a new account with no personal details loaded yet (e.g. right after a cache clear), for an // email delegate mockUseVacationDelegatePersonalDetails.mockReturnValue(undefined); @@ -204,9 +201,9 @@ describe('VacationDelegateMenuItem', () => { // When the menu item renders that delegate renderMenuItem(EMAIL_DELEGATE); - // Then the raw email is used for both title and description, since there is no name to fall back on - // and an email login has nothing to format - expect(textOccurrences(EMAIL_DELEGATE)).toBe(2); + // Then the pill shows the raw email, since there is no name to fall back on and an email login has + // nothing to format + expect(textOccurrences(EMAIL_DELEGATE)).toBe(1); expect(screen.queryByText(/@expensify\.sms/)).toBeNull(); }); @@ -220,7 +217,7 @@ describe('VacationDelegateMenuItem', () => { // Then the number is still localized from the login alone, rather than showing the raw E.164 login // or leaking its SMS domain - expect(textOccurrences(PHONE_DELEGATE_LOCALIZED)).toBe(2); + expect(textOccurrences(PHONE_DELEGATE_LOCALIZED)).toBe(1); expect(screen.queryByText(PHONE_DELEGATE_RAW)).toBeNull(); expect(screen.queryByText(/@expensify\.sms/)).toBeNull(); }); @@ -276,7 +273,8 @@ describe('VacationDelegateMenuItem', () => { />, ); - // Then the "until" line is shown under the login, so the vacationer and admins can see when it ends - expect(screen.getByText(`${EMAIL_DELEGATE}\nstatusPage.vacationDelegate.until`)).toBeTruthy(); + // Then the "until" line is shown under the pill, so the vacationer and admins can see when it ends + expect(screen.getByText('Jane Doe')).toBeTruthy(); + expect(screen.getByText('statusPage.vacationDelegate.until')).toBeTruthy(); }); }); From 4ecae1498807914a59eef13965d25c16831e8ba5 Mon Sep 17 00:00:00 2001 From: "Kevin Brian Bader (via MelvinBot)" Date: Wed, 30 Sep 2026 04:21:18 +0000 Subject: [PATCH 03/12] Fix vacation delegate navigation and match the date input to the mockups - Title both member pickers "Choose delegate". - Push the form from a picker opened straight from Profile or member details, and pop only the form on back, so back returns to the picker. - Close the whole RHP after Invite or Skip on the missing workspaces step. Its removal guard now replays the blocked action through the navigation container; replaying it through the screen stamped the screen's key as the source, so the root stack dropped it. - Keep a solid calendar icon on the clear after input after a date is picked, and hide its clear button. - Tighten the gap under the delegate row and give the remove button the same top spacing with or without the clear-on note. Co-authored-by: Kevin Brian Bader --- src/components/DatePicker/index.tsx | 4 +- src/components/DatePicker/types.ts | 7 ++ src/components/VacationDelegateForm.tsx | 23 ++++-- .../DomainMemberVacationDelegateFormPage.tsx | 8 +- .../DomainMemberVacationDelegatePage.tsx | 15 +++- .../CustomStatus/VacationDelegateFormPage.tsx | 8 +- .../index.tsx | 11 +-- .../CustomStatus/VacationDelegatePage.tsx | 14 +++- tests/ui/VacationDelegateFormPageTest.tsx | 22 ++++++ ...ationDelegateMissingWorkspacesPageTest.tsx | 76 +++++++++++++++++-- tests/ui/VacationDelegatePageTest.tsx | 54 +++++++++++-- 11 files changed, 208 insertions(+), 34 deletions(-) diff --git a/src/components/DatePicker/index.tsx b/src/components/DatePicker/index.tsx index feb75af8775c..c4ea4f0acaa9 100644 --- a/src/components/DatePicker/index.tsx +++ b/src/components/DatePicker/index.tsx @@ -52,7 +52,9 @@ function DatePicker({ rightHandSideComponent, onPickerVisibilityChange, shouldHideCalendarIcon = false, + shouldKeepCalendarIconWhenSelected = false, shouldForceActiveLabel = true, + icon, }: DateInputWithPickerProps) { const icons = useMemoizedLazyExpensifyIcons(['Calendar']); const styles = useThemeStyles(); @@ -235,7 +237,7 @@ function DatePicker({ ref={combinedTextInputRef} inputID={inputID} forceActiveLabel={shouldForceActiveLabel} - icon={selectedDate || shouldHideCalendarIcon ? null : icons.Calendar} + icon={(selectedDate && !shouldKeepCalendarIconWhenSelected) || shouldHideCalendarIcon ? null : (icon ?? icons.Calendar)} iconContainerStyle={styles.pr0} label={label} accessibilityLabel={label} diff --git a/src/components/DatePicker/types.ts b/src/components/DatePicker/types.ts index 03b477c37f71..992ab8e202ef 100644 --- a/src/components/DatePicker/types.ts +++ b/src/components/DatePicker/types.ts @@ -83,6 +83,13 @@ type DateInputWithPickerProps = DatePickerBaseProps & */ shouldHideCalendarIcon?: boolean; + /** + * Keeps the trailing calendar icon once a date is picked, instead of leaving that space to the clear button. + * Pair it with `shouldHideClearButton` when the date should only be changed through the picker. + * @default false + */ + shouldKeepCalendarIconWhenSelected?: boolean; + /** * Keeps the label above the input even when it is empty and not focused, so the date format placeholder always shows. * Pass `false` to let the empty label sit inside the input, like a regular text input. diff --git a/src/components/VacationDelegateForm.tsx b/src/components/VacationDelegateForm.tsx index 2b02d4550ecf..aef8ec2d9237 100644 --- a/src/components/VacationDelegateForm.tsx +++ b/src/components/VacationDelegateForm.tsx @@ -57,7 +57,7 @@ type VacationDelegateFormProps = { function VacationDelegateForm({vacationDelegate, description, onChangeDelegate, onSubmit, onRemove, errors, pendingAction, onCloseError}: VacationDelegateFormProps) { const styles = useThemeStyles(); const {translate, dateFnsLocale} = useLocalize(); - const icons = useMemoizedLazyExpensifyIcons(['Trashcan']); + const icons = useMemoizedLazyExpensifyIcons(['CalendarSolid', 'Trashcan']); const {timezone} = useCurrentUserPersonalDetails(); const [draftValues] = useOnyx(ONYXKEYS.FORMS.VACATION_DELEGATE_FORM_DRAFT); @@ -105,24 +105,31 @@ function VacationDelegateForm({vacationDelegate, description, onChangeDelegate, onCloseError={onCloseError} onPress={onChangeDelegate} /> - + + {/* The date can only be changed through the picker, so it keeps its icon and has no clear button, as in the mockups */} - {!!formattedClearDate && {translate('statusPage.vacationDelegate.willClearOn', formattedClearDate)}} + {/* The bottom margin matches the date input's own, so the remove button below sits the same distance from whichever is last */} + {!!formattedClearDate && {translate('statusPage.vacationDelegate.willClearOn', formattedClearDate)}} {!!savedDelegate && ( - + + + )} ); diff --git a/src/pages/domain/Members/DomainMemberVacationDelegateFormPage.tsx b/src/pages/domain/Members/DomainMemberVacationDelegateFormPage.tsx index d623fe2a3398..785e932cf637 100644 --- a/src/pages/domain/Members/DomainMemberVacationDelegateFormPage.tsx +++ b/src/pages/domain/Members/DomainMemberVacationDelegateFormPage.tsx @@ -44,6 +44,12 @@ function DomainMemberVacationDelegateFormPage({route}: DomainMemberVacationDeleg Navigation.goBack(ROUTES.DOMAIN_MEMBER_DETAILS.getRoute(domainAccountID, accountID)); }; + // Pops only this form, so a member picker opened straight from the member details page is shown again. + const goBack = () => { + clearDraftValues(ONYXKEYS.FORMS.VACATION_DELEGATE_FORM); + Navigation.goBack(); + }; + const onSubmit = (delegate: string, clearAfter: string | undefined) => { if (!memberLogin) { return; @@ -71,7 +77,7 @@ function DomainMemberVacationDelegateFormPage({route}: DomainMemberVacationDeleg > state.routes.some((stackRoute) => stackRoute.name === SCREENS.DOMAIN.VACATION_DELEGATE)); const [personalDetails] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST, { selector: personalDetailsSelector(accountID), @@ -52,7 +54,14 @@ function DomainMemberVacationDelegatePage({route}: DomainMemberVacationDelegateP // The pick is only saved from the form, where the clear after date is chosen too. setDraftValues(ONYXKEYS.FORMS.VACATION_DELEGATE_FORM, {[INPUT_IDS.DELEGATE]: delegateLogin}); - Navigation.goBack(ROUTES.DOMAIN_VACATION_DELEGATE.getRoute(domainAccountID, accountID)); + + // Opened from the form's delegate row, so go back to it. Opened straight from the member details page, the form comes + // next, so push it and keep this picker under it for the form's back button. + if (isFormInStack) { + Navigation.goBack(ROUTES.DOMAIN_VACATION_DELEGATE.getRoute(domainAccountID, accountID)); + return; + } + Navigation.navigate(ROUTES.DOMAIN_VACATION_DELEGATE.getRoute(domainAccountID, accountID)); }; return ( @@ -63,7 +72,7 @@ function DomainMemberVacationDelegatePage({route}: DomainMemberVacationDelegateP > Navigation.goBack()} cannotSetDelegateMessage={translate('domain.members.cannotSetVacationDelegateForMember', memberLogin ?? '')} diff --git a/src/pages/settings/Profile/CustomStatus/VacationDelegateFormPage.tsx b/src/pages/settings/Profile/CustomStatus/VacationDelegateFormPage.tsx index a5a752e52aaa..9d0d6545f77d 100644 --- a/src/pages/settings/Profile/CustomStatus/VacationDelegateFormPage.tsx +++ b/src/pages/settings/Profile/CustomStatus/VacationDelegateFormPage.tsx @@ -25,6 +25,12 @@ function goBackToProfile() { Navigation.goBack(ROUTES.SETTINGS_PROFILE.route); } +// Pops only this form, so a member picker opened straight from Profile is shown again. +function goBack() { + clearDraftValues(ONYXKEYS.FORMS.VACATION_DELEGATE_FORM); + Navigation.goBack(); +} + function VacationDelegateFormPage() { const {translate} = useLocalize(); const {login: currentUserLogin = ''} = useCurrentUserPersonalDetails(); @@ -104,7 +110,7 @@ function VacationDelegateFormPage() { > ) => draft?.[INPUT_IDS.DELEGATE]; @@ -24,6 +26,7 @@ function VacationDelegatePage() { const [vacationDelegate] = useOnyx(ONYXKEYS.NVP_PRIVATE_VACATION_DELEGATE); const [draftDelegate] = useOnyx(ONYXKEYS.FORMS.VACATION_DELEGATE_FORM_DRAFT, {selector: draftDelegateSelector}); + const isFormInStack = useNavigationState((state) => state.routes.some((route) => route.name === SCREENS.SETTINGS.PROFILE.VACATION_DELEGATE)); const onSelectRow = (option: Participant) => { if (!option?.login) { @@ -32,7 +35,14 @@ function VacationDelegatePage() { // The pick is only saved from the form, where the clear after date is chosen too. setDraftValues(ONYXKEYS.FORMS.VACATION_DELEGATE_FORM, {[INPUT_IDS.DELEGATE]: option.login}); - Navigation.goBack(ROUTES.SETTINGS_VACATION_DELEGATE); + + // Opened from the form's delegate row, so go back to it. Opened straight from Profile, the form comes next, so push it + // and keep this picker under it for the form's back button. + if (isFormInStack) { + Navigation.goBack(ROUTES.SETTINGS_VACATION_DELEGATE); + return; + } + Navigation.navigate(ROUTES.SETTINGS_VACATION_DELEGATE); }; return ( @@ -44,7 +54,7 @@ function VacationDelegatePage() { Navigation.goBack()} cannotSetDelegateMessage={translate('statusPage.cannotSetVacationDelegate')} includeCurrentUser={false} diff --git a/tests/ui/VacationDelegateFormPageTest.tsx b/tests/ui/VacationDelegateFormPageTest.tsx index d7c454b002c7..2e86d9859789 100644 --- a/tests/ui/VacationDelegateFormPageTest.tsx +++ b/tests/ui/VacationDelegateFormPageTest.tsx @@ -39,6 +39,7 @@ const ORIGINAL_CLEAR_AFTER = '2026-09-30 06:59:59'; jest.mock('@libs/Navigation/Navigation', () => ({ navigate: jest.fn(), goBack: jest.fn(), + getTopmostReportId: jest.fn(), getActiveRoute: jest.fn(() => ''), getActiveRouteWithoutParams: jest.fn(() => ''), isNavigationReady: jest.fn(() => Promise.resolve()), @@ -222,6 +223,27 @@ describe('VacationDelegateFormPage', () => { expect(Navigation.goBack).toHaveBeenCalledWith(ROUTES.SETTINGS_PROFILE.route); }); + it('pops only the form and drops the unsaved pick when the header back button is pressed', async () => { + // Given a member picked on the picker but not saved yet + jest.mocked(Navigation.goBack).mockClear(); + await act(async () => { + await Onyx.set(ONYXKEYS.FORMS.VACATION_DELEGATE_FORM_DRAFT, {delegate: DELEGATE_A_EMAIL}); + }); + + renderPage(); + await waitForBatchedUpdatesWithAct(); + + // When the header back button is pressed + fireEvent.press(screen.getByLabelText(TestHelper.translateLocal('common.back'))); + await waitForBatchedUpdatesWithAct(); + + // Then only this screen is popped, so a picker opened straight from Profile shows again instead of the whole RHP closing, + // and the unsaved pick is cleared on the way out + expect(Navigation.goBack).toHaveBeenCalledWith(); + const draft = await getOnyxValue(ONYXKEYS.FORMS.VACATION_DELEGATE_FORM_DRAFT); + expect(draft?.delegate).toBeFalsy(); + }); + it('surfaces the EXP_ERROR response message in the error modal and restores the previous delegate on dismissal', async () => { // Given a response carrying a server-provided EXP_ERROR message, since a regression once dropped this message // for the generic copy and this page owns the guarantee that it reaches the user diff --git a/tests/ui/VacationDelegateMissingWorkspacesPageTest.tsx b/tests/ui/VacationDelegateMissingWorkspacesPageTest.tsx index 41600baf6015..c738159ff2d8 100644 --- a/tests/ui/VacationDelegateMissingWorkspacesPageTest.tsx +++ b/tests/ui/VacationDelegateMissingWorkspacesPageTest.tsx @@ -534,14 +534,14 @@ describe('VacationDelegateMissingWorkspacesPage', () => { expect(rows.at(1)).toHaveTextContent('Zebra Workspace'); }); - it('sends the clear after datetime picked on the form again and returns to the profile page', async () => { + it('sends the clear after datetime picked on the form again and closes the flow', async () => { // Given a 305 that parked a pick made with a clear after date, since the form step is gone by the time this step saves it const clearAfter = '2026-10-02 06:59:59'; await seedVacationDelegate({adminPolicies: [], nonAdminPolicies: [MEMBER_POLICY_ID]}); await act(async () => { await Onyx.merge(ONYXKEYS.NVP_PRIVATE_VACATION_DELEGATE, {pendingClearAfter: clearAfter}); }); - const goBackSpy = jest.spyOn(Navigation, 'goBack').mockImplementation(() => {}); + const dismissModalSpy = jest.spyOn(Navigation, 'dismissModal').mockImplementation(() => {}); renderPage(); await waitForBatchedUpdatesWithAct(); @@ -549,13 +549,75 @@ describe('VacationDelegateMissingWorkspacesPage', () => { fireEvent.press(screen.getByRole('button', {name: TestHelper.translateLocal('common.confirm')})); await waitForBatchedUpdatesWithAct(); - // Then the date is saved with the delegate instead of being dropped, and the user lands back on the profile page + // Then the date is saved with the delegate instead of being dropped, and the whole RHP closes back onto the profile page expect(apiWriteSpy).toHaveBeenCalledWith( WRITE_COMMANDS.SET_VACATION_DELEGATE, expect.objectContaining({vacationDelegateEmail: DELEGATE_EMAIL, clearAfter, overridePolicyDiffWarning: true}), expect.anything(), ); - expect(goBackSpy).toHaveBeenCalledWith(ROUTES.SETTINGS_PROFILE.route); + expect(dismissModalSpy).toHaveBeenCalledTimes(1); + }); + + it('closes the flow and clears the form draft when Skip is pressed', async () => { + // Given the admin-of step with a pick still in the form draft + await seedVacationDelegate({adminPolicies: [ADMIN_POLICY_ID], nonAdminPolicies: []}); + await act(async () => { + await Onyx.set(ONYXKEYS.FORMS.VACATION_DELEGATE_FORM_DRAFT, {delegate: DELEGATE_EMAIL}); + }); + const dismissModalSpy = jest.spyOn(Navigation, 'dismissModal').mockImplementation(() => {}); + renderPage(); + await waitForBatchedUpdatesWithAct(); + + // When Skip is pressed + fireEvent.press(screen.getByRole('button', {name: TestHelper.translateLocal('common.skip')})); + await waitForBatchedUpdatesWithAct(); + + // Then the delegate is saved and the whole RHP closes, so the form and picker under this step don't linger, and the draft is gone + expect(apiWriteSpy).toHaveBeenCalledWith(WRITE_COMMANDS.SET_VACATION_DELEGATE, expect.objectContaining({overridePolicyDiffWarning: true}), expect.anything()); + expect(dismissModalSpy).toHaveBeenCalledTimes(1); + const draft = await getOnyxValue(ONYXKEYS.FORMS.VACATION_DELEGATE_FORM_DRAFT); + expect(draft?.delegate).toBeFalsy(); + }); + + it('closes the flow and clears the form draft when Invite is pressed', async () => { + // Given the admin-of step with a pick still in the form draft + await seedVacationDelegate({adminPolicies: [ADMIN_POLICY_ID], nonAdminPolicies: []}); + await act(async () => { + await Onyx.set(ONYXKEYS.FORMS.VACATION_DELEGATE_FORM_DRAFT, {delegate: DELEGATE_EMAIL}); + }); + const dismissModalSpy = jest.spyOn(Navigation, 'dismissModal').mockImplementation(() => {}); + renderPage(); + await waitForBatchedUpdatesWithAct(); + + // When Invite is pressed + fireEvent.press(screen.getByRole('button', {name: TestHelper.translateLocal('common.invite')})); + await waitForBatchedUpdatesWithAct(); + + // Then the delegate is invited and saved, the whole RHP closes, and the draft is gone + expect(apiWriteSpy).toHaveBeenCalledWith(WRITE_COMMANDS.ADD_MEMBERS_TO_WORKSPACE, expect.anything(), expect.anything()); + expect(apiWriteSpy).toHaveBeenCalledWith(WRITE_COMMANDS.SET_VACATION_DELEGATE, expect.objectContaining({overridePolicyDiffWarning: true}), expect.anything()); + expect(dismissModalSpy).toHaveBeenCalledTimes(1); + const draft = await getOnyxValue(ONYXKEYS.FORMS.VACATION_DELEGATE_FORM_DRAFT); + expect(draft?.delegate).toBeFalsy(); + }); + + it('replays the blocked action through the navigation container so a navigator above this stack can still apply it', async () => { + // Given the step is guarding its removal + await seedVacationDelegate({adminPolicies: [], nonAdminPolicies: [MEMBER_POLICY_ID]}); + renderPage(); + await waitForBatchedUpdatesWithAct(); + const containerDispatchSpy = jest.spyOn(navigationRef, 'dispatch').mockImplementation(() => {}); + const blockedAction = {type: CONST.NAVIGATION.ACTION_TYPE.DISMISS_MODAL}; + + // When the guard lets a blocked action through, such as the one closing the whole RHP + await act(async () => { + mockPreventRemoveCallback?.({data: {action: blockedAction}}); + await waitForBatchedUpdatesWithAct(); + }); + + // Then it is replayed as is through the container. Replaying it through this screen would stamp the screen's key on it as + // its source, and the root stack, which can't find that key among its own routes, would drop it and leave the RHP open + expect(containerDispatchSpy).toHaveBeenCalledWith(blockedAction); }); it('still asks the backend to email the non-admin workspaces when Skip is pressed on a mixed diff', async () => { @@ -608,9 +670,9 @@ describe('VacationDelegateMissingWorkspacesPage', () => { }); it('keeps rendering what was submitted when the flow state is cleared underneath it', async () => { - // Given Navigation.goBack stubbed out, so the screen stays mounted past submission the same way it does in - // the real app, where navigation only pops this screen once the transition finishes - jest.spyOn(Navigation, 'goBack').mockImplementation(() => {}); + // Given Navigation.dismissModal stubbed out, so the screen stays mounted past submission the same way it does in + // the real app, where navigation only removes this screen once the transition finishes + jest.spyOn(Navigation, 'dismissModal').mockImplementation(() => {}); await seedVacationDelegate({adminPolicies: [], nonAdminPolicies: [MEMBER_POLICY_ID]}); renderPage(); await waitForBatchedUpdatesWithAct(); diff --git a/tests/ui/VacationDelegatePageTest.tsx b/tests/ui/VacationDelegatePageTest.tsx index d73010af7feb..6a77c4f48523 100644 --- a/tests/ui/VacationDelegatePageTest.tsx +++ b/tests/ui/VacationDelegatePageTest.tsx @@ -6,12 +6,15 @@ import {LocaleContextProvider} from '@components/LocaleContextProvider'; import OnyxListItemProvider from '@components/OnyxListItemProvider'; import Navigation from '@libs/Navigation/Navigation'; +import createPlatformStackNavigator from '@libs/Navigation/PlatformStackNavigation/createPlatformStackNavigator'; +import type {SettingsNavigatorParamList} from '@libs/Navigation/types'; import VacationDelegatePage from '@pages/settings/Profile/CustomStatus/VacationDelegatePage'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; +import SCREENS from '@src/SCREENS'; // eslint-disable-next-line no-restricted-imports -- React Native Pressable/Text are required only to type the actual Jest module export; this does not import them at runtime. import type {Pressable as ReactNativePressable, Text as ReactNativeText} from 'react-native'; @@ -28,6 +31,8 @@ const CREATOR_ACCOUNT_ID = 1; const CREATOR_EMAIL = 'creator@example.com'; const DELEGATE_A_EMAIL = 'delegateA@example.com'; +const Stack = createPlatformStackNavigator(); + jest.mock('@libs/Navigation/Navigation', () => ({ navigate: jest.fn(), goBack: jest.fn(), @@ -46,11 +51,31 @@ jest.mock('@components/BaseVacationDelegateSelectionComponent', () => { ReactMock.createElement(Pressable, {testID: 'select-delegate-a', onPress: () => onSelectRow({login: 'delegateA@example.com'})}, ReactMock.createElement(Text, null, 'select-a')); }); -function renderPage() { +/** + * Renders the picker in a stack, with the form under it when `isFormInStack` is set, which is how the stack looks when the + * picker is opened from the form's delegate row rather than straight from Profile. + */ +function renderPage(isFormInStack = false) { + const initialState = isFormInStack + ? { + index: 1, + routes: [{name: SCREENS.SETTINGS.PROFILE.VACATION_DELEGATE}, {name: SCREENS.SETTINGS.PROFILE.VACATION_DELEGATE_SELECT}], + } + : undefined; + return render( - + - + + null} + /> + + , ); @@ -63,6 +88,7 @@ describe('VacationDelegatePage', () => { beforeEach(async () => { jest.mocked(Navigation.goBack).mockClear(); + jest.mocked(Navigation.navigate).mockClear(); await act(async () => { await Onyx.set(ONYXKEYS.NVP_PREFERRED_LOCALE, CONST.LOCALES.EN); }); @@ -78,8 +104,8 @@ describe('VacationDelegatePage', () => { await waitForBatchedUpdatesWithAct(); }); - it('stores the picked member on the form instead of saving it', async () => { - // Given a picker with no saved delegate, and the API spied on so any save would be visible + it('stores the picked member on the form instead of saving it, and opens the form on top of the picker', async () => { + // Given a picker opened straight from Profile with no saved delegate, and the API spied on so any save would be visible const apiSideEffectSpy = jest.spyOn(require('@libs/API'), 'makeRequestWithSideEffects'); renderPage(); @@ -89,11 +115,27 @@ describe('VacationDelegatePage', () => { fireEvent.press(screen.getByTestId('select-delegate-a')); await waitForBatchedUpdatesWithAct(); - // Then nothing is saved yet, since the clear after date is chosen on the form, and the pick is handed to the form + // Then nothing is saved yet, since the clear after date is chosen on the form, and the pick is handed to the form. + // The form is pushed rather than swapped in for the picker, so its back button returns to the picker expect(apiSideEffectSpy).not.toHaveBeenCalled(); const draft = await getOnyxValue(ONYXKEYS.FORMS.VACATION_DELEGATE_FORM_DRAFT); expect(draft?.delegate).toBe(DELEGATE_A_EMAIL); + expect(Navigation.navigate).toHaveBeenCalledWith(ROUTES.SETTINGS_VACATION_DELEGATE); + expect(Navigation.goBack).not.toHaveBeenCalled(); + }); + + it('goes back to the form when the picker was opened from it', async () => { + // Given the picker was opened from the form's delegate row, so the form is already under it + renderPage(true); + await waitForBatchedUpdatesWithAct(); + + // When a member is picked + fireEvent.press(screen.getByTestId('select-delegate-a')); + await waitForBatchedUpdatesWithAct(); + + // Then the picker pops back to that form instead of pushing a second one on top expect(Navigation.goBack).toHaveBeenCalledWith(ROUTES.SETTINGS_VACATION_DELEGATE); + expect(Navigation.navigate).not.toHaveBeenCalled(); }); it('does not delete the saved delegate when the same member is picked again', async () => { From f7788198d503b8e975548210225f3fd8b4456ff2 Mon Sep 17 00:00:00 2001 From: "Kevin Brian Bader (via MelvinBot)" Date: Thu, 1 Oct 2026 02:42:29 +0000 Subject: [PATCH 04/12] Let users pick a time as well as a date for the vacation delegate to clear Co-authored-by: Kevin Brian Bader --- .../settings/Delegate-when-out-of-office.md | 6 +- src/components/VacationDelegateForm.tsx | 46 ++++++++++++-- src/components/VacationDelegateMenuItem.tsx | 4 +- src/libs/DateUtils.ts | 3 +- src/libs/VacationDelegateUtils.ts | 59 +++++++++++++---- src/pages/settings/Profile/ProfilePage.tsx | 4 +- src/types/form/VacationDelegateForm.ts | 4 ++ tests/unit/libs/VacationDelegateUtilsTest.ts | 63 +++++++++++++++---- 8 files changed, 152 insertions(+), 37 deletions(-) diff --git a/docs/articles/new-expensify/settings/Delegate-when-out-of-office.md b/docs/articles/new-expensify/settings/Delegate-when-out-of-office.md index 1caaf08f33f7..1df676784bec 100644 --- a/docs/articles/new-expensify/settings/Delegate-when-out-of-office.md +++ b/docs/articles/new-expensify/settings/Delegate-when-out-of-office.md @@ -9,7 +9,7 @@ internalScope: Audience is all members. Covers assigning, scheduling the end of, If you're out of the office or on vacation, you can assign a vacation delegate to handle your approvals while you're away. -A vacation delegate can approve reports on your behalf. Once assigned, any reports sent to you for approval will be automatically redirected to your delegate. Pick a **Clear after** date and the delegate is removed automatically at the end of that day, so reports come back to you when you return. +A vacation delegate can approve reports on your behalf. Once assigned, any reports sent to you for approval will be automatically redirected to your delegate. Pick a **Clear after** date and time and the delegate is removed automatically at that time, so reports come back to you when you return. All delegate actions are tracked in the report history for full visibility. @@ -19,10 +19,10 @@ All delegate actions are tracked in the report history for full visibility. 1. Go to **Account > Profile > Vacation delegate**. 2. Select the member who should approve on your behalf. -3. Optional (recommended): Under **Clear after**, pick the last day the delegate should cover. +3. Optional (recommended): Under **Clear after**, pick the last day the delegate should cover. Then, under **Time**, pick when the delegate should clear on that day. If you don't change the time, the delegate clears at the end of the day. 4. Click **Save**. -Once set, any reports or chats that would normally come to you will be redirected to your delegate. You can view every action they take in the report's history and comments. The delegate and the date it clears are shown under **Vacation delegate** on your profile. +Once set, any reports or chats that would normally come to you will be redirected to your delegate. You can view every action they take in the report's history and comments. The delegate and the date and time it clears are shown under **Vacation delegate** on your profile. ## How to remove a vacation delegate diff --git a/src/components/VacationDelegateForm.tsx b/src/components/VacationDelegateForm.tsx index aef8ec2d9237..887f9c9361d9 100644 --- a/src/components/VacationDelegateForm.tsx +++ b/src/components/VacationDelegateForm.tsx @@ -4,7 +4,15 @@ import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; import useThemeStyles from '@hooks/useThemeStyles'; -import {formatVacationDelegateClearDate, getVacationDelegateClearAfter, getVacationDelegateClearDate, isVacationDelegateExpired} from '@libs/VacationDelegateUtils'; +import DateUtils from '@libs/DateUtils'; +import { + formatVacationDelegateClearDateTime, + getVacationDelegateClearAfter, + getVacationDelegateClearDate, + getVacationDelegateClearDateTime, + getVacationDelegateLocalClearDateTime, + isVacationDelegateExpired, +} from '@libs/VacationDelegateUtils'; import {getDatePassedError} from '@libs/ValidationUtils'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -22,6 +30,7 @@ import FormProvider from './Form/FormProvider'; import InputWrapper from './Form/InputWrapper'; import MenuItemAction from './MenuItem/presets/MenuItemAction'; import Text from './Text'; +import TimeModalPicker from './TimeModalPicker'; import VacationDelegateMenuItem from './VacationDelegateMenuItem'; type VacationDelegateFormProps = { @@ -62,16 +71,27 @@ function VacationDelegateForm({vacationDelegate, description, onChangeDelegate, const [draftValues] = useOnyx(ONYXKEYS.FORMS.VACATION_DELEGATE_FORM_DRAFT); const savedDelegate = isVacationDelegateExpired(vacationDelegate?.clearAfter) ? undefined : vacationDelegate?.delegate; + const savedClearDateTime = savedDelegate ? getVacationDelegateClearDateTime(vacationDelegate?.clearAfter, timezone?.selected) : ''; const savedClearDate = savedDelegate ? getVacationDelegateClearDate(vacationDelegate?.clearAfter, timezone?.selected) : ''; // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing -- an empty draft means no member was picked yet const delegate = draftValues?.[INPUT_IDS.DELEGATE] || savedDelegate; - const formattedClearDate = formatVacationDelegateClearDate(draftValues?.[INPUT_IDS.CLEAR_AFTER_DATE] ?? savedClearDate, dateFnsLocale); + const clearDate = draftValues?.[INPUT_IDS.CLEAR_AFTER_DATE] ?? savedClearDate; + const formattedClearDateTime = formatVacationDelegateClearDateTime( + getVacationDelegateLocalClearDateTime(clearDate, draftValues?.[INPUT_IDS.CLEAR_AFTER_TIME] ?? savedClearDateTime), + dateFnsLocale, + ); const validate = (values: FormOnyxValues): FormInputErrors => { const formErrors: FormInputErrors = {}; - const dateError = values[INPUT_IDS.CLEAR_AFTER_DATE] ? getDatePassedError(translate, values[INPUT_IDS.CLEAR_AFTER_DATE]) : ''; + if (!values[INPUT_IDS.CLEAR_AFTER_DATE]) { + return formErrors; + } + + const dateError = getDatePassedError(translate, values[INPUT_IDS.CLEAR_AFTER_DATE]); if (dateError) { formErrors[INPUT_IDS.CLEAR_AFTER_DATE] = dateError; + } else if (isVacationDelegateExpired(getVacationDelegateClearAfter(values[INPUT_IDS.CLEAR_AFTER_DATE], values[INPUT_IDS.CLEAR_AFTER_TIME], timezone?.selected))) { + formErrors[INPUT_IDS.CLEAR_AFTER_TIME] = translate('common.error.invalidTimeShouldBeFuture'); } return formErrors; }; @@ -81,7 +101,7 @@ function VacationDelegateForm({vacationDelegate, description, onChangeDelegate, return; } - onSubmit(delegate, getVacationDelegateClearAfter(values[INPUT_IDS.CLEAR_AFTER_DATE], timezone?.selected)); + onSubmit(delegate, getVacationDelegateClearAfter(values[INPUT_IDS.CLEAR_AFTER_DATE], values[INPUT_IDS.CLEAR_AFTER_TIME], timezone?.selected)); }; return ( @@ -119,9 +139,23 @@ function VacationDelegateForm({vacationDelegate, description, onChangeDelegate, shouldHideClearButton shouldSaveDraft /> - {/* The bottom margin matches the date input's own, so the remove button below sits the same distance from whichever is last */} - {!!formattedClearDate && {translate('statusPage.vacationDelegate.willClearOn', formattedClearDate)}} + {/* The time only means something once a day is picked. Until it is changed, the delegate clears at the end of that day. */} + {!!clearDate && ( + + + + )} + {/* The bottom margin matches the date input's own, so the remove button below sits the same distance from whichever is last */} + {!!formattedClearDateTime && ( + {translate('statusPage.vacationDelegate.willClearOn', formattedClearDateTime)} + )} {!!savedDelegate && ( ; @@ -17,6 +20,7 @@ type VacationDelegateForm = Form< { [INPUT_IDS.DELEGATE]: string; [INPUT_IDS.CLEAR_AFTER_DATE]: string; + [INPUT_IDS.CLEAR_AFTER_TIME]: string; } >; diff --git a/tests/unit/libs/VacationDelegateUtilsTest.ts b/tests/unit/libs/VacationDelegateUtilsTest.ts index 6f0a2bb31e7c..b520047b82f3 100644 --- a/tests/unit/libs/VacationDelegateUtilsTest.ts +++ b/tests/unit/libs/VacationDelegateUtilsTest.ts @@ -1,4 +1,11 @@ -import {formatVacationDelegateClearDate, getVacationDelegateClearAfter, getVacationDelegateClearDate, isVacationDelegateExpired} from '@libs/VacationDelegateUtils'; +import { + formatVacationDelegateClearDateTime, + getVacationDelegateClearAfter, + getVacationDelegateClearDate, + getVacationDelegateClearDateTime, + getVacationDelegateLocalClearDateTime, + isVacationDelegateExpired, +} from '@libs/VacationDelegateUtils'; describe('VacationDelegateUtils', () => { beforeEach(() => { @@ -10,19 +17,53 @@ describe('VacationDelegateUtils', () => { jest.useRealTimers(); }); + describe('getVacationDelegateLocalClearDateTime', () => { + it('takes the day from the date and only the time from the time picker value', () => { + // Given a picked day and a time picker value whose own date is a different day + // When they are combined + // Then the result keeps the picked day, since the time picker's date part is meaningless + expect(getVacationDelegateLocalClearDateTime('2026-10-01', '2026-09-28 17:30:00')).toBe('2026-10-01 17:30:00'); + }); + + it('falls back to the end of the day when no time is picked, and to nothing when no day is picked', () => { + // Given a day without a time, and no day at all + // When they are combined + // Then the day ends at 23:59:59 so the delegate covers all of it, and no day means the delegate never clears + expect(getVacationDelegateLocalClearDateTime('2026-10-01', undefined)).toBe('2026-10-01 23:59:59'); + expect(getVacationDelegateLocalClearDateTime('', '2026-09-28 17:30:00')).toBe(''); + }); + }); + describe('getVacationDelegateClearAfter', () => { - it('converts the picked day to the UTC end of that day in the given timezone', () => { - // Given a day picked in Los Angeles, where Oct 1 ends at 06:59:59 UTC on Oct 2 during daylight saving time + it('converts the picked day and time to UTC in the given timezone', () => { + // Given 5:30 PM on Oct 1 picked in Los Angeles, which is 7 hours behind UTC during daylight saving time + // When it is converted for the backend + // Then the backend gets the same moment in UTC + expect(getVacationDelegateClearAfter('2026-10-01', '2026-09-28 17:30:00', 'America/Los_Angeles')).toBe('2026-10-02 00:30:00'); + }); + + it('converts a day without a time to the UTC end of that day in the given timezone', () => { + // Given a day picked in Los Angeles with no time, where Oct 1 ends at 06:59:59 UTC on Oct 2 during daylight saving time // When it is converted for the backend // Then the delegate stays active for the whole picked day, not just until midnight UTC - expect(getVacationDelegateClearAfter('2026-10-01', 'America/Los_Angeles')).toBe('2026-10-02 06:59:59'); + expect(getVacationDelegateClearAfter('2026-10-01', undefined, 'America/Los_Angeles')).toBe('2026-10-02 06:59:59'); }); it('returns undefined when no day is picked, so the delegate never clears', () => { // Given no picked day // When it is converted for the backend // Then nothing is sent, since the date is optional - expect(getVacationDelegateClearAfter('', 'America/Los_Angeles')).toBeUndefined(); + expect(getVacationDelegateClearAfter('', '2026-09-28 17:30:00', 'America/Los_Angeles')).toBeUndefined(); + }); + }); + + describe('getVacationDelegateClearDateTime', () => { + it('converts the UTC clear after datetime back into the picked day and time', () => { + // Given the datetime the backend stored for 5:30 PM on Oct 1 picked in Los Angeles + // When it is read back in the same timezone + // Then it shows the day and time that were picked, not the UTC ones + expect(getVacationDelegateClearDateTime('2026-10-02 00:30:00', 'America/Los_Angeles')).toBe('2026-10-01 17:30:00'); + expect(getVacationDelegateClearDateTime(undefined, 'America/Los_Angeles')).toBe(''); }); }); @@ -35,13 +76,13 @@ describe('VacationDelegateUtils', () => { }); }); - describe('formatVacationDelegateClearDate', () => { - it('formats the day for display and ignores an invalid one', () => { - // Given a valid and an invalid day + describe('formatVacationDelegateClearDateTime', () => { + it('formats the day and time for display and ignores an invalid one', () => { + // Given a valid and an invalid datetime // When they are formatted - // Then the valid one reads as a short date and the invalid one shows nothing instead of throwing - expect(formatVacationDelegateClearDate('2026-10-01', undefined)).toBe('Oct 1, 2026'); - expect(formatVacationDelegateClearDate('not a date', undefined)).toBe(''); + // Then the valid one reads as a short date with a time and the invalid one shows nothing instead of throwing + expect(formatVacationDelegateClearDateTime('2026-10-01 17:30:00', undefined)).toBe('Oct 1, 2026 5:30 PM'); + expect(formatVacationDelegateClearDateTime('not a date', undefined)).toBe(''); }); }); From 593f42fe682dfcb9e22920d9d3554ba99d8f1e9c Mon Sep 17 00:00:00 2001 From: "Kevin Brian Bader (via MelvinBot)" Date: Fri, 2 Oct 2026 01:14:33 +0000 Subject: [PATCH 05/12] Use the composed Header and a memoized useOnyx selector on the vacation delegate form pages Co-authored-by: Kevin Brian Bader --- .../Members/DomainMemberVacationDelegateFormPage.tsx | 9 +++++---- .../Profile/CustomStatus/VacationDelegateFormPage.tsx | 4 ++-- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/src/pages/domain/Members/DomainMemberVacationDelegateFormPage.tsx b/src/pages/domain/Members/DomainMemberVacationDelegateFormPage.tsx index 785e932cf637..b6d802032c51 100644 --- a/src/pages/domain/Members/DomainMemberVacationDelegateFormPage.tsx +++ b/src/pages/domain/Members/DomainMemberVacationDelegateFormPage.tsx @@ -1,4 +1,4 @@ -import HeaderWithBackButton from '@components/HeaderWithBackButton'; +import HeaderWithBackButtonAndTitle from '@components/Header/composed/HeaderWithBackButtonAndTitle'; import ScreenWrapper from '@components/ScreenWrapper'; import VacationDelegateForm from '@components/VacationDelegateForm'; @@ -22,7 +22,7 @@ import ROUTES from '@src/ROUTES'; import type SCREENS from '@src/SCREENS'; import {vacationDelegateSelector} from '@selectors/Domain'; -import React from 'react'; +import React, {useMemo} from 'react'; type DomainMemberVacationDelegateFormPageProps = PlatformStackScreenProps; @@ -32,8 +32,9 @@ function DomainMemberVacationDelegateFormPage({route}: DomainMemberVacationDeleg const {login: currentUserLogin} = useCurrentUserPersonalDetails(); + const memberVacationDelegateSelector = useMemo(() => vacationDelegateSelector(accountID), [accountID]); const [vacationDelegate] = useOnyx(`${ONYXKEYS.COLLECTION.DOMAIN}${domainAccountID}`, { - selector: vacationDelegateSelector(accountID), + selector: memberVacationDelegateSelector, }); const [personalDetails] = usePersonalDetail(accountID); @@ -75,7 +76,7 @@ function DomainMemberVacationDelegateFormPage({route}: DomainMemberVacationDeleg testID="DomainMemberVacationDelegateFormPage" shouldEnableMaxHeight > - diff --git a/src/pages/settings/Profile/CustomStatus/VacationDelegateFormPage.tsx b/src/pages/settings/Profile/CustomStatus/VacationDelegateFormPage.tsx index 9d0d6545f77d..23167ce66e10 100644 --- a/src/pages/settings/Profile/CustomStatus/VacationDelegateFormPage.tsx +++ b/src/pages/settings/Profile/CustomStatus/VacationDelegateFormPage.tsx @@ -1,4 +1,4 @@ -import HeaderWithBackButton from '@components/HeaderWithBackButton'; +import HeaderWithBackButtonAndTitle from '@components/Header/composed/HeaderWithBackButtonAndTitle'; import ScreenWrapper from '@components/ScreenWrapper'; import VacationDelegateForm from '@components/VacationDelegateForm'; @@ -108,7 +108,7 @@ function VacationDelegateFormPage() { testID="VacationDelegateFormPage" shouldEnableMaxHeight > - From 006d89242fa8514129280086888848b6ca886bad Mon Sep 17 00:00:00 2001 From: "Kevin Brian Bader (via MelvinBot)" Date: Wed, 7 Oct 2026 23:14:02 +0000 Subject: [PATCH 06/12] Address review comments on the vacation delegate form - Require the clear after time to be at least one minute ahead, matching the error copy - Show the delegator list instead of the form while the user is someone else's delegate - Pass the domain vacation delegate selector inline, as React Compiler memoizes it - Split the help doc step into action-led steps for the date and time Co-authored-by: Kevin Brian Bader --- .../settings/Delegate-when-out-of-office.md | 5 +-- src/components/VacationDelegateForm.tsx | 3 +- src/libs/VacationDelegateUtils.ts | 12 ++++++- .../DomainMemberVacationDelegateFormPage.tsx | 5 ++- .../CustomStatus/VacationDelegateFormPage.tsx | 35 +++++++++++++------ tests/ui/VacationDelegateFormPageTest.tsx | 16 +++++++++ tests/unit/libs/VacationDelegateUtilsTest.ts | 12 +++++++ 7 files changed, 71 insertions(+), 17 deletions(-) diff --git a/docs/articles/new-expensify/settings/Delegate-when-out-of-office.md b/docs/articles/new-expensify/settings/Delegate-when-out-of-office.md index 5eab976fc0f5..8d3097664a55 100644 --- a/docs/articles/new-expensify/settings/Delegate-when-out-of-office.md +++ b/docs/articles/new-expensify/settings/Delegate-when-out-of-office.md @@ -19,8 +19,9 @@ All delegate actions are tracked in the report history for full visibility. 1. Go to **Account > Profile > Vacation delegate**. 2. Select the member who should approve on your behalf. -3. Optional (recommended): Under **Clear after**, pick the last day the delegate should cover. Then, under **Time**, pick when the delegate should clear on that day. If you don't change the time, the delegate clears at the end of the day. -4. Click **Save**. +3. Under **Clear after (recommended)**, pick the last day the delegate should cover (optional). +4. Under **Time**, pick when the delegate clears on that day (defaults to the end of the day). +5. Click **Save**. Once set, any reports or chats that would normally come to you will be redirected to your delegate. You can view every action they take in the report's history and comments. The delegate and the date and time it clears are shown under **Vacation delegate** on your profile. diff --git a/src/components/VacationDelegateForm.tsx b/src/components/VacationDelegateForm.tsx index 887f9c9361d9..2ac867b6e5bc 100644 --- a/src/components/VacationDelegateForm.tsx +++ b/src/components/VacationDelegateForm.tsx @@ -11,6 +11,7 @@ import { getVacationDelegateClearDate, getVacationDelegateClearDateTime, getVacationDelegateLocalClearDateTime, + isVacationDelegateClearAfterTooSoon, isVacationDelegateExpired, } from '@libs/VacationDelegateUtils'; import {getDatePassedError} from '@libs/ValidationUtils'; @@ -90,7 +91,7 @@ function VacationDelegateForm({vacationDelegate, description, onChangeDelegate, const dateError = getDatePassedError(translate, values[INPUT_IDS.CLEAR_AFTER_DATE]); if (dateError) { formErrors[INPUT_IDS.CLEAR_AFTER_DATE] = dateError; - } else if (isVacationDelegateExpired(getVacationDelegateClearAfter(values[INPUT_IDS.CLEAR_AFTER_DATE], values[INPUT_IDS.CLEAR_AFTER_TIME], timezone?.selected))) { + } else if (isVacationDelegateClearAfterTooSoon(getVacationDelegateClearAfter(values[INPUT_IDS.CLEAR_AFTER_DATE], values[INPUT_IDS.CLEAR_AFTER_TIME], timezone?.selected))) { formErrors[INPUT_IDS.CLEAR_AFTER_TIME] = translate('common.error.invalidTimeShouldBeFuture'); } return formErrors; diff --git a/src/libs/VacationDelegateUtils.ts b/src/libs/VacationDelegateUtils.ts index 4cebb3ee6ae1..43dce0a20e1d 100644 --- a/src/libs/VacationDelegateUtils.ts +++ b/src/libs/VacationDelegateUtils.ts @@ -3,7 +3,7 @@ import type {SelectedTimezone} from '@src/types/onyx/PersonalDetails'; import type {Locale as DateFnsLocale} from 'date-fns'; -import {format, isValid, parse} from 'date-fns'; +import {addMinutes, format, isValid, parse} from 'date-fns'; import {fromZonedTime} from 'date-fns-tz'; import DateUtils from './DateUtils'; @@ -79,6 +79,15 @@ function isVacationDelegateExpired(clearAfter: string | undefined): boolean { return !!clearAfter && clearAfter <= DateUtils.getDBTime(); } +/** + * Whether the picked clearAfter datetime is less than one minute from now. Such a delegate could clear while the request is still in + * flight, so the form asks for a time at least one minute ahead. + */ +function isVacationDelegateClearAfterTooSoon(clearAfter: string | undefined): boolean { + // Both values are UTC in the database format, so they compare as strings. + return !!clearAfter && clearAfter < DateUtils.formatDBTimeWithoutMilliseconds(addMinutes(new Date(), 1).valueOf()); +} + export { getVacationDelegateClearAfter, getVacationDelegateClearDate, @@ -86,4 +95,5 @@ export { getVacationDelegateLocalClearDateTime, formatVacationDelegateClearDateTime, isVacationDelegateExpired, + isVacationDelegateClearAfterTooSoon, }; diff --git a/src/pages/domain/Members/DomainMemberVacationDelegateFormPage.tsx b/src/pages/domain/Members/DomainMemberVacationDelegateFormPage.tsx index b6d802032c51..fcf58dfd335a 100644 --- a/src/pages/domain/Members/DomainMemberVacationDelegateFormPage.tsx +++ b/src/pages/domain/Members/DomainMemberVacationDelegateFormPage.tsx @@ -22,7 +22,7 @@ import ROUTES from '@src/ROUTES'; import type SCREENS from '@src/SCREENS'; import {vacationDelegateSelector} from '@selectors/Domain'; -import React, {useMemo} from 'react'; +import React from 'react'; type DomainMemberVacationDelegateFormPageProps = PlatformStackScreenProps; @@ -32,9 +32,8 @@ function DomainMemberVacationDelegateFormPage({route}: DomainMemberVacationDeleg const {login: currentUserLogin} = useCurrentUserPersonalDetails(); - const memberVacationDelegateSelector = useMemo(() => vacationDelegateSelector(accountID), [accountID]); const [vacationDelegate] = useOnyx(`${ONYXKEYS.COLLECTION.DOMAIN}${domainAccountID}`, { - selector: memberVacationDelegateSelector, + selector: vacationDelegateSelector(accountID), }); const [personalDetails] = usePersonalDetail(accountID); diff --git a/src/pages/settings/Profile/CustomStatus/VacationDelegateFormPage.tsx b/src/pages/settings/Profile/CustomStatus/VacationDelegateFormPage.tsx index 23167ce66e10..e1fb8b2de6d0 100644 --- a/src/pages/settings/Profile/CustomStatus/VacationDelegateFormPage.tsx +++ b/src/pages/settings/Profile/CustomStatus/VacationDelegateFormPage.tsx @@ -1,3 +1,4 @@ +import DelegatorList from '@components/DelegatorList'; import HeaderWithBackButtonAndTitle from '@components/Header/composed/HeaderWithBackButtonAndTitle'; import ScreenWrapper from '@components/ScreenWrapper'; import VacationDelegateForm from '@components/VacationDelegateForm'; @@ -6,6 +7,7 @@ import useConfirmModal from '@hooks/useConfirmModal'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; +import useThemeStyles from '@hooks/useThemeStyles'; import {clearDraftValues} from '@libs/actions/FormActions'; import {clearVacationDelegateError, deleteVacationDelegate, setVacationDelegate} from '@libs/actions/VacationDelegate'; @@ -19,6 +21,7 @@ import {isEmptyObject} from '@src/types/utils/EmptyObject'; import {useNavigation} from '@react-navigation/native'; import React, {useRef} from 'react'; +import {View} from 'react-native'; function goBackToProfile() { clearDraftValues(ONYXKEYS.FORMS.VACATION_DELEGATE_FORM); @@ -33,11 +36,13 @@ function goBack() { function VacationDelegateFormPage() { const {translate} = useLocalize(); + const styles = useThemeStyles(); const {login: currentUserLogin = ''} = useCurrentUserPersonalDetails(); const {showConfirmModal} = useConfirmModal(); const navigation = useNavigation(); const [vacationDelegate] = useOnyx(ONYXKEYS.NVP_PRIVATE_VACATION_DELEGATE); + const hasActiveDelegations = !!vacationDelegate?.delegatorFor?.length; const isSubmittingRef = useRef(false); @@ -112,16 +117,26 @@ function VacationDelegateFormPage() { title={translate('common.vacationDelegate')} onBackButtonPress={goBack} /> - Navigation.navigate(ROUTES.SETTINGS_VACATION_DELEGATE_SELECT)} - onSubmit={onSubmit} - onRemove={onRemove} - errors={getVacationDelegateErrors(vacationDelegate)} - pendingAction={vacationDelegate?.pendingAction} - onCloseError={() => clearVacationDelegateError(vacationDelegate?.previousDelegate, vacationDelegate?.previousClearAfter)} - /> + {/* While the user is someone else's delegate, they can't change their own, same as on the member picker */} + {hasActiveDelegations ? ( + + + + ) : ( + Navigation.navigate(ROUTES.SETTINGS_VACATION_DELEGATE_SELECT)} + onSubmit={onSubmit} + onRemove={onRemove} + errors={getVacationDelegateErrors(vacationDelegate)} + pendingAction={vacationDelegate?.pendingAction} + onCloseError={() => clearVacationDelegateError(vacationDelegate?.previousDelegate, vacationDelegate?.previousClearAfter)} + /> + )} ); } diff --git a/tests/ui/VacationDelegateFormPageTest.tsx b/tests/ui/VacationDelegateFormPageTest.tsx index 2e86d9859789..17b5a402ab6a 100644 --- a/tests/ui/VacationDelegateFormPageTest.tsx +++ b/tests/ui/VacationDelegateFormPageTest.tsx @@ -223,6 +223,22 @@ describe('VacationDelegateFormPage', () => { expect(Navigation.goBack).toHaveBeenCalledWith(ROUTES.SETTINGS_PROFILE.route); }); + it('shows the delegator list instead of the form while the user is someone else’s delegate', async () => { + // Given a saved delegate, while the user is also the vacation delegate for another member + await act(async () => { + await Onyx.set(ONYXKEYS.NVP_PRIVATE_VACATION_DELEGATE, {creator: CREATOR_EMAIL, delegate: ORIGINAL_DELEGATE_EMAIL, delegatorFor: [DELEGATE_A_EMAIL]}); + }); + + // When the form page opens + renderPage(); + await waitForBatchedUpdatesWithAct(); + + // Then the form is replaced by the explanation, so the user can't change or remove their own delegate, same as on the member picker + expect(screen.getByText(TestHelper.translateLocal('statusPage.cannotSetVacationDelegate'))).toBeOnTheScreen(); + expect(screen.queryByTestId('select-delegate-a')).not.toBeOnTheScreen(); + expect(screen.queryByTestId('remove-delegate')).not.toBeOnTheScreen(); + }); + it('pops only the form and drops the unsaved pick when the header back button is pressed', async () => { // Given a member picked on the picker but not saved yet jest.mocked(Navigation.goBack).mockClear(); diff --git a/tests/unit/libs/VacationDelegateUtilsTest.ts b/tests/unit/libs/VacationDelegateUtilsTest.ts index b520047b82f3..da5de9ff102d 100644 --- a/tests/unit/libs/VacationDelegateUtilsTest.ts +++ b/tests/unit/libs/VacationDelegateUtilsTest.ts @@ -4,6 +4,7 @@ import { getVacationDelegateClearDate, getVacationDelegateClearDateTime, getVacationDelegateLocalClearDateTime, + isVacationDelegateClearAfterTooSoon, isVacationDelegateExpired, } from '@libs/VacationDelegateUtils'; @@ -96,4 +97,15 @@ describe('VacationDelegateUtils', () => { expect(isVacationDelegateExpired(undefined)).toBe(false); }); }); + + describe('isVacationDelegateClearAfterTooSoon', () => { + it('is true when the clear after datetime is less than one minute from now', () => { + // Given the current time is 2026-09-28 12:00 UTC + // When a time 30 seconds ahead, exactly one minute ahead, and no time are checked + // Then only the one under a minute ahead is too soon, since it could clear while the save request is in flight + expect(isVacationDelegateClearAfterTooSoon('2026-09-28 12:00:30')).toBe(true); + expect(isVacationDelegateClearAfterTooSoon('2026-09-28 12:01:00')).toBe(false); + expect(isVacationDelegateClearAfterTooSoon(undefined)).toBe(false); + }); + }); }); From fcf9bdfa6eda6b9f2e7c2c8f5b311755f6bff495 Mon Sep 17 00:00:00 2001 From: "Kevin Brian Bader (via MelvinBot)" Date: Thu, 8 Oct 2026 19:11:43 +0000 Subject: [PATCH 07/12] Validate the vacation delegate clear date in the Profile timezone Co-authored-by: Kevin Brian Bader --- src/components/VacationDelegateForm.tsx | 15 ++++++------- src/libs/VacationDelegateUtils.ts | 13 ++++++++++++ tests/unit/libs/VacationDelegateUtilsTest.ts | 22 ++++++++++++++++++++ 3 files changed, 43 insertions(+), 7 deletions(-) diff --git a/src/components/VacationDelegateForm.tsx b/src/components/VacationDelegateForm.tsx index 2ac867b6e5bc..28499e7eb1e5 100644 --- a/src/components/VacationDelegateForm.tsx +++ b/src/components/VacationDelegateForm.tsx @@ -12,9 +12,9 @@ import { getVacationDelegateClearDateTime, getVacationDelegateLocalClearDateTime, isVacationDelegateClearAfterTooSoon, + isVacationDelegateClearDatePassed, isVacationDelegateExpired, } from '@libs/VacationDelegateUtils'; -import {getDatePassedError} from '@libs/ValidationUtils'; import ONYXKEYS from '@src/ONYXKEYS'; import INPUT_IDS from '@src/types/form/VacationDelegateForm'; @@ -66,7 +66,7 @@ type VacationDelegateFormProps = { function VacationDelegateForm({vacationDelegate, description, onChangeDelegate, onSubmit, onRemove, errors, pendingAction, onCloseError}: VacationDelegateFormProps) { const styles = useThemeStyles(); - const {translate, dateFnsLocale} = useLocalize(); + const {translate, dateFnsLocale, getLocalDateFromDatetime} = useLocalize(); const icons = useMemoizedLazyExpensifyIcons(['CalendarSolid', 'Trashcan']); const {timezone} = useCurrentUserPersonalDetails(); const [draftValues] = useOnyx(ONYXKEYS.FORMS.VACATION_DELEGATE_FORM_DRAFT); @@ -88,9 +88,9 @@ function VacationDelegateForm({vacationDelegate, description, onChangeDelegate, return formErrors; } - const dateError = getDatePassedError(translate, values[INPUT_IDS.CLEAR_AFTER_DATE]); - if (dateError) { - formErrors[INPUT_IDS.CLEAR_AFTER_DATE] = dateError; + // Both checks read the picked date and time in the Profile timezone, the same one clearAfter is built from, not the device's + if (isVacationDelegateClearDatePassed(values[INPUT_IDS.CLEAR_AFTER_DATE], timezone?.selected)) { + formErrors[INPUT_IDS.CLEAR_AFTER_DATE] = translate('common.error.dateInvalid'); } else if (isVacationDelegateClearAfterTooSoon(getVacationDelegateClearAfter(values[INPUT_IDS.CLEAR_AFTER_DATE], values[INPUT_IDS.CLEAR_AFTER_TIME], timezone?.selected))) { formErrors[INPUT_IDS.CLEAR_AFTER_TIME] = translate('common.error.invalidTimeShouldBeFuture'); } @@ -127,13 +127,14 @@ function VacationDelegateForm({vacationDelegate, description, onChangeDelegate, onPress={onChangeDelegate} /> - {/* The date can only be changed through the picker, so it keeps its icon and has no clear button, as in the mockups */} + {/* The date can only be changed through the picker, so it keeps its icon and has no clear button, as in the mockups. + The earliest day is today in the Profile timezone, so it matches the validation even when the device is in another timezone. */} { }); }); + describe('isVacationDelegateClearDatePassed', () => { + it('reads the clear date in the Profile timezone, not the device timezone', () => { + // Given it is 2026-10-08 03:00 UTC: still Oct 7 in Los Angeles, already Oct 8 in Tokyo, and Oct 8 on the UTC test device + jest.setSystemTime(new Date('2026-10-08T03:00:00Z')); + + // When Oct 7 is checked for a user whose Profile timezone is Los Angeles, and for one whose Profile timezone is Tokyo + // Then it is still open in Los Angeles, even though the device's day has moved on, and it has ended in Tokyo + expect(isVacationDelegateClearDatePassed('2026-10-07', 'America/Los_Angeles')).toBe(false); + expect(isVacationDelegateClearDatePassed('2026-10-07', 'Asia/Tokyo')).toBe(true); + expect(isVacationDelegateClearDatePassed('2026-10-06', 'America/Los_Angeles')).toBe(true); + expect(isVacationDelegateClearDatePassed('2026-10-08', 'Asia/Tokyo')).toBe(false); + }); + + it('treats a date that does not parse as passed', () => { + // Given a clear date that isn't a yyyy-MM-dd date + // When it is checked + // Then it is rejected, so the form shows the invalid date error instead of building a clearAfter from it + expect(isVacationDelegateClearDatePassed('not-a-date', 'America/Los_Angeles')).toBe(true); + }); + }); + describe('isVacationDelegateClearAfterTooSoon', () => { it('is true when the clear after datetime is less than one minute from now', () => { // Given the current time is 2026-09-28 12:00 UTC From 91e069c7132441aa18cbc8001d7051fc1ff5ed9f Mon Sep 17 00:00:00 2001 From: "Kevin Brian Bader (via MelvinBot)" Date: Fri, 9 Oct 2026 20:03:29 +0000 Subject: [PATCH 08/12] Address C+ review on vacation delegates: domain guard, member timezone, shared expiry helper - Show the DelegatorList on the domain admin form while the member is someone else's delegate - Pick and show a domain member's clear date in the member's timezone - Add getActiveVacationDelegate and use it on every screen that shows or opens the delegate - Skip the personal details lookup for an expired delegate - Use one route const for the Profile row - Type the domain optimistic delegate without an as cast - Use one term for delegate in the el and nl chooseDelegate strings Co-authored-by: Kevin Brian Bader --- src/components/VacationDelegateForm.tsx | 32 +++--- src/components/VacationDelegateMenuItem.tsx | 20 ++-- src/languages/el.ts | 2 +- src/languages/nl.ts | 2 +- src/libs/VacationDelegateUtils.ts | 10 ++ src/libs/actions/Domain.ts | 17 ++-- .../Members/DomainMemberDetailsPage.tsx | 5 +- .../DomainMemberVacationDelegateFormPage.tsx | 27 +++-- src/pages/settings/Profile/ProfilePage.tsx | 18 ++-- ...mainMemberVacationDelegateFormPageTest.tsx | 98 +++++++++++++++++++ .../VacationDelegateMenuItemTest.tsx | 3 +- tests/unit/libs/VacationDelegateUtilsTest.ts | 13 +++ 12 files changed, 201 insertions(+), 46 deletions(-) create mode 100644 tests/ui/DomainMemberVacationDelegateFormPageTest.tsx diff --git a/src/components/VacationDelegateForm.tsx b/src/components/VacationDelegateForm.tsx index 28499e7eb1e5..006c5595d8da 100644 --- a/src/components/VacationDelegateForm.tsx +++ b/src/components/VacationDelegateForm.tsx @@ -7,18 +7,19 @@ import useThemeStyles from '@hooks/useThemeStyles'; import DateUtils from '@libs/DateUtils'; import { formatVacationDelegateClearDateTime, + getActiveVacationDelegate, getVacationDelegateClearAfter, getVacationDelegateClearDate, getVacationDelegateClearDateTime, getVacationDelegateLocalClearDateTime, isVacationDelegateClearAfterTooSoon, isVacationDelegateClearDatePassed, - isVacationDelegateExpired, } from '@libs/VacationDelegateUtils'; import ONYXKEYS from '@src/ONYXKEYS'; import INPUT_IDS from '@src/types/form/VacationDelegateForm'; import type {Errors, PendingAction} from '@src/types/onyx/OnyxCommon'; +import type {SelectedTimezone} from '@src/types/onyx/PersonalDetails'; import type {BaseVacationDelegate} from '@src/types/onyx/VacationDelegate'; import React from 'react'; @@ -41,6 +42,12 @@ type VacationDelegateFormProps = { /** Text shown above the form */ description?: string; + /** + * Timezone the clear date and time are picked in. Defaults to the current user's Profile timezone. + * A domain admin passes the member's, so the delegate clears at the end of the vacationer's day, not the admin's. + */ + timezone?: SelectedTimezone; + /** Opens the member selection screen. The picked member is stored in the form draft. */ onChangeDelegate: () => void; @@ -64,16 +71,17 @@ type VacationDelegateFormProps = { onCloseError?: () => void; }; -function VacationDelegateForm({vacationDelegate, description, onChangeDelegate, onSubmit, onRemove, errors, pendingAction, onCloseError}: VacationDelegateFormProps) { +function VacationDelegateForm({vacationDelegate, description, timezone: timezoneProp, onChangeDelegate, onSubmit, onRemove, errors, pendingAction, onCloseError}: VacationDelegateFormProps) { const styles = useThemeStyles(); const {translate, dateFnsLocale, getLocalDateFromDatetime} = useLocalize(); const icons = useMemoizedLazyExpensifyIcons(['CalendarSolid', 'Trashcan']); - const {timezone} = useCurrentUserPersonalDetails(); + const {timezone: currentUserTimezone} = useCurrentUserPersonalDetails(); + const timezone = timezoneProp ?? currentUserTimezone?.selected; const [draftValues] = useOnyx(ONYXKEYS.FORMS.VACATION_DELEGATE_FORM_DRAFT); - const savedDelegate = isVacationDelegateExpired(vacationDelegate?.clearAfter) ? undefined : vacationDelegate?.delegate; - const savedClearDateTime = savedDelegate ? getVacationDelegateClearDateTime(vacationDelegate?.clearAfter, timezone?.selected) : ''; - const savedClearDate = savedDelegate ? getVacationDelegateClearDate(vacationDelegate?.clearAfter, timezone?.selected) : ''; + const savedDelegate = getActiveVacationDelegate(vacationDelegate); + const savedClearDateTime = savedDelegate ? getVacationDelegateClearDateTime(vacationDelegate?.clearAfter, timezone) : ''; + const savedClearDate = savedDelegate ? getVacationDelegateClearDate(vacationDelegate?.clearAfter, timezone) : ''; // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing -- an empty draft means no member was picked yet const delegate = draftValues?.[INPUT_IDS.DELEGATE] || savedDelegate; const clearDate = draftValues?.[INPUT_IDS.CLEAR_AFTER_DATE] ?? savedClearDate; @@ -88,10 +96,10 @@ function VacationDelegateForm({vacationDelegate, description, onChangeDelegate, return formErrors; } - // Both checks read the picked date and time in the Profile timezone, the same one clearAfter is built from, not the device's - if (isVacationDelegateClearDatePassed(values[INPUT_IDS.CLEAR_AFTER_DATE], timezone?.selected)) { + // Both checks read the picked date and time in the form's timezone, the same one clearAfter is built from, not the device's + if (isVacationDelegateClearDatePassed(values[INPUT_IDS.CLEAR_AFTER_DATE], timezone)) { formErrors[INPUT_IDS.CLEAR_AFTER_DATE] = translate('common.error.dateInvalid'); - } else if (isVacationDelegateClearAfterTooSoon(getVacationDelegateClearAfter(values[INPUT_IDS.CLEAR_AFTER_DATE], values[INPUT_IDS.CLEAR_AFTER_TIME], timezone?.selected))) { + } else if (isVacationDelegateClearAfterTooSoon(getVacationDelegateClearAfter(values[INPUT_IDS.CLEAR_AFTER_DATE], values[INPUT_IDS.CLEAR_AFTER_TIME], timezone))) { formErrors[INPUT_IDS.CLEAR_AFTER_TIME] = translate('common.error.invalidTimeShouldBeFuture'); } return formErrors; @@ -102,7 +110,7 @@ function VacationDelegateForm({vacationDelegate, description, onChangeDelegate, return; } - onSubmit(delegate, getVacationDelegateClearAfter(values[INPUT_IDS.CLEAR_AFTER_DATE], values[INPUT_IDS.CLEAR_AFTER_TIME], timezone?.selected)); + onSubmit(delegate, getVacationDelegateClearAfter(values[INPUT_IDS.CLEAR_AFTER_DATE], values[INPUT_IDS.CLEAR_AFTER_TIME], timezone)); }; return ( @@ -128,13 +136,13 @@ function VacationDelegateForm({vacationDelegate, description, onChangeDelegate, /> {/* The date can only be changed through the picker, so it keeps its icon and has no clear button, as in the mockups. - The earliest day is today in the Profile timezone, so it matches the validation even when the device is in another timezone. */} + The earliest day is today in the form's timezone, so it matches the validation even when the device is in another timezone. */} void; }; -function VacationDelegateMenuItemPreset({vacationDelegate, label, errors, pendingAction, onCloseError, onPress}: VacationDelegateSectionProps) { +function VacationDelegateMenuItemPreset({vacationDelegate, label, timezone, errors, pendingAction, onCloseError, onPress}: VacationDelegateSectionProps) { const styles = useThemeStyles(); const {translate, formatPhoneNumber, dateFnsLocale} = useLocalize(); - const {timezone} = useCurrentUserPersonalDetails(); + const {timezone: currentUserTimezone} = useCurrentUserPersonalDetails(); const rowLabel = label ?? translate('common.vacationDelegate'); - const hasVacationDelegate = !!vacationDelegate?.delegate && !isVacationDelegateExpired(vacationDelegate?.clearAfter); - const vacationDelegatePersonalDetails = useVacationDelegatePersonalDetails(vacationDelegate?.delegate); + const activeVacationDelegate = getActiveVacationDelegate(vacationDelegate); + const hasVacationDelegate = !!activeVacationDelegate; + // Only looks up a delegate the row shows, so an expired one doesn't trigger a personal details request + const vacationDelegatePersonalDetails = useVacationDelegatePersonalDetails(activeVacationDelegate); - const rawDelegateLogin = vacationDelegatePersonalDetails?.login ?? vacationDelegate?.delegate ?? ''; + const rawDelegateLogin = vacationDelegatePersonalDetails?.login ?? activeVacationDelegate ?? ''; const delegateDisplayName = getVacationDelegateDisplayName(rawDelegateLogin, vacationDelegatePersonalDetails?.displayName, formatPhoneNumber); - const clearDate = formatVacationDelegateClearDateTime(getVacationDelegateClearDateTime(vacationDelegate?.clearAfter, timezone?.selected), dateFnsLocale); + const clearDate = formatVacationDelegateClearDateTime(getVacationDelegateClearDateTime(vacationDelegate?.clearAfter, timezone ?? currentUserTimezone?.selected), dateFnsLocale); const untilText = clearDate ? translate('statusPage.vacationDelegate.until', clearDate) : ''; return ( diff --git a/src/languages/el.ts b/src/languages/el.ts index 4dd53a116a5e..6370ed2929b4 100644 --- a/src/languages/el.ts +++ b/src/languages/el.ts @@ -3955,7 +3955,7 @@ ${amount} για ${merchant} - ${date}`, `Ο/Η ${delegate} δεν είναι μέλος των παρακάτω χώρων εργασίας. Θα ζητηθεί από τους διαχειριστές των χώρων εργασίας που δεν διαχειρίζεστε να τον/την προσθέσουν. Θέλετε να τον/την προσκαλέσετε τώρα σε αυτούς που διαχειρίζεστε ως διαχειριστής;`, youAreAMemberOf: 'Είστε μέλος αυτών των χώρων εργασίας:', youAreAnAdminOf: 'Είστε διαχειριστής αυτών των χώρων εργασίας:', - chooseDelegate: 'Επιλέξτε αναπληρωτή', + chooseDelegate: 'Επιλέξτε εκπρόσωπο', clearAfterRecommended: 'Εκκαθάριση μετά από (συνιστάται)', willClearOn: (date: string) => `Ο εκπρόσωπος διακοπών θα καταργηθεί στις ${date}`, until: (date: string) => `Έως ${date}`, diff --git a/src/languages/nl.ts b/src/languages/nl.ts index 8a061f5d9e27..a06a1ce22d63 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -3890,7 +3890,7 @@ ${amount} voor ${merchant} - ${date}`, `${delegate} is geen lid van de onderstaande werkruimtes. Beheerders van de werkruimtes die je niet beheert, wordt gevraagd diegene toe te voegen. Wil je diegene nu uitnodigen voor de werkruimtes waar je beheerder van bent?`, youAreAMemberOf: 'Je bent lid van deze werkruimtes:', youAreAnAdminOf: 'Je bent beheerder van deze werkruimtes:', - chooseDelegate: 'Gedelegeerde kiezen', + chooseDelegate: 'Vertegenwoordiger kiezen', clearAfterRecommended: 'Wissen na (aanbevolen)', willClearOn: (date: string) => `De vertegenwoordiger tijdens vakantie wordt gewist op ${date}`, until: (date: string) => `Tot ${date}`, diff --git a/src/libs/VacationDelegateUtils.ts b/src/libs/VacationDelegateUtils.ts index f40271c4f9f5..1f7bf9b8ebc8 100644 --- a/src/libs/VacationDelegateUtils.ts +++ b/src/libs/VacationDelegateUtils.ts @@ -1,5 +1,6 @@ import CONST from '@src/CONST'; import type {SelectedTimezone} from '@src/types/onyx/PersonalDetails'; +import type {BaseVacationDelegate} from '@src/types/onyx/VacationDelegate'; import type {Locale as DateFnsLocale} from 'date-fns'; @@ -79,6 +80,14 @@ function isVacationDelegateExpired(clearAfter: string | undefined): boolean { return !!clearAfter && clearAfter <= DateUtils.getDBTime(); } +/** + * The delegate still in effect, ignoring one whose clearAfter has passed. Every screen that shows or opens the delegate uses this, + * so they all apply the same expiry rule. + */ +function getActiveVacationDelegate(vacationDelegate: BaseVacationDelegate | undefined): string | undefined { + return vacationDelegate?.delegate && !isVacationDelegateExpired(vacationDelegate.clearAfter) ? vacationDelegate.delegate : undefined; +} + /** * Whether the picked clear date (yyyy-MM-dd) has already ended in the given timezone. The day is read in the user's Profile timezone, * not the device's, so a device in another timezone doesn't reject a day that hasn't ended yet for the user, or accept one that has. @@ -107,6 +116,7 @@ export { getVacationDelegateLocalClearDateTime, formatVacationDelegateClearDateTime, isVacationDelegateExpired, + getActiveVacationDelegate, isVacationDelegateClearDatePassed, isVacationDelegateClearAfterTooSoon, }; diff --git a/src/libs/actions/Domain.ts b/src/libs/actions/Domain.ts index f0fc6f31b7c5..e9e3970b0331 100644 --- a/src/libs/actions/Domain.ts +++ b/src/libs/actions/Domain.ts @@ -1672,19 +1672,20 @@ function setDomainVacationDelegate( vacationDelegate?: BaseVacationDelegate, clearAfter?: string, ) { + const optimisticVacationDelegate: NullishDeep = { + delegate, + creator, + clearAfter: clearAfter ?? null, + previousDelegate: vacationDelegate?.delegate, + previousClearAfter: vacationDelegate?.clearAfter ?? null, + }; const optimisticData: Array> = [ { onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.DOMAIN}${domainAccountID}`, value: { - [`${CONST.DOMAIN.PRIVATE_VACATION_DELEGATE_PREFIX}${domainMemberAccountID}`]: { - delegate, - creator, - clearAfter: clearAfter ?? null, - previousDelegate: vacationDelegate?.delegate, - previousClearAfter: vacationDelegate?.clearAfter ?? null, - }, - } as PrefixedRecord>, + [`${CONST.DOMAIN.PRIVATE_VACATION_DELEGATE_PREFIX}${domainMemberAccountID}` as const]: optimisticVacationDelegate, + }, }, { onyxMethod: Onyx.METHOD.MERGE, diff --git a/src/pages/domain/Members/DomainMemberDetailsPage.tsx b/src/pages/domain/Members/DomainMemberDetailsPage.tsx index 2a4d9e0d26e7..703c34871809 100644 --- a/src/pages/domain/Members/DomainMemberDetailsPage.tsx +++ b/src/pages/domain/Members/DomainMemberDetailsPage.tsx @@ -25,7 +25,7 @@ import { import {clearDraftValues} from '@libs/actions/FormActions'; import {requestUnlockAccount} from '@libs/actions/User'; import {getLatestError} from '@libs/ErrorUtils'; -import {isVacationDelegateExpired} from '@libs/VacationDelegateUtils'; +import {getActiveVacationDelegate} from '@libs/VacationDelegateUtils'; import Navigation from '@navigation/Navigation'; import type {PlatformStackScreenProps} from '@navigation/PlatformStackNavigation/types'; @@ -167,11 +167,12 @@ function DomainMemberDetailsPage({route}: DomainMemberDetailsPageProps) { { clearDraftValues(ONYXKEYS.FORMS.VACATION_DELEGATE_FORM); // With no delegate there is nothing to show on the form yet, so go straight to picking one. Navigation.navigate( - vacationDelegate?.delegate && !isVacationDelegateExpired(vacationDelegate.clearAfter) + getActiveVacationDelegate(vacationDelegate) ? ROUTES.DOMAIN_VACATION_DELEGATE.getRoute(domainAccountID, accountID) : ROUTES.DOMAIN_VACATION_DELEGATE_SELECT.getRoute(domainAccountID, accountID), ); diff --git a/src/pages/domain/Members/DomainMemberVacationDelegateFormPage.tsx b/src/pages/domain/Members/DomainMemberVacationDelegateFormPage.tsx index fcf58dfd335a..e6d0e8eb3320 100644 --- a/src/pages/domain/Members/DomainMemberVacationDelegateFormPage.tsx +++ b/src/pages/domain/Members/DomainMemberVacationDelegateFormPage.tsx @@ -1,3 +1,4 @@ +import DelegatorList from '@components/DelegatorList'; import HeaderWithBackButtonAndTitle from '@components/Header/composed/HeaderWithBackButtonAndTitle'; import ScreenWrapper from '@components/ScreenWrapper'; import VacationDelegateForm from '@components/VacationDelegateForm'; @@ -6,6 +7,7 @@ import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails' import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; import {usePersonalDetail} from '@hooks/usePersonalDetails'; +import useThemeStyles from '@hooks/useThemeStyles'; import {clearDraftValues} from '@libs/actions/FormActions'; import Navigation from '@libs/Navigation/Navigation'; @@ -23,12 +25,14 @@ import type SCREENS from '@src/SCREENS'; import {vacationDelegateSelector} from '@selectors/Domain'; import React from 'react'; +import {View} from 'react-native'; type DomainMemberVacationDelegateFormPageProps = PlatformStackScreenProps; function DomainMemberVacationDelegateFormPage({route}: DomainMemberVacationDelegateFormPageProps) { const {domainAccountID, accountID} = route.params; const {translate} = useLocalize(); + const styles = useThemeStyles(); const {login: currentUserLogin} = useCurrentUserPersonalDetails(); @@ -79,12 +83,23 @@ function DomainMemberVacationDelegateFormPage({route}: DomainMemberVacationDeleg title={translate('common.vacationDelegate')} onBackButtonPress={goBack} /> - Navigation.navigate(ROUTES.DOMAIN_VACATION_DELEGATE_SELECT.getRoute(domainAccountID, accountID))} - onSubmit={onSubmit} - onRemove={onRemove} - /> + {/* While the member is someone else's delegate, their own can't be changed, same as on the member picker */} + {vacationDelegate?.delegatorFor?.length ? ( + + + + ) : ( + Navigation.navigate(ROUTES.DOMAIN_VACATION_DELEGATE_SELECT.getRoute(domainAccountID, accountID))} + onSubmit={onSubmit} + onRemove={onRemove} + /> + )} ); diff --git a/src/pages/settings/Profile/ProfilePage.tsx b/src/pages/settings/Profile/ProfilePage.tsx index d6a282e71c0d..63505fd81324 100755 --- a/src/pages/settings/Profile/ProfilePage.tsx +++ b/src/pages/settings/Profile/ProfilePage.tsx @@ -41,7 +41,7 @@ import type {PlatformStackRouteProp} from '@libs/Navigation/PlatformStackNavigat import type {SettingsSplitNavigatorParamList} from '@libs/Navigation/types'; import {getFormattedAddress, temporaryGetDisplayNameOrDefault} from '@libs/PersonalDetailsUtils'; import {expensifyLoginsSelector, getContactMethodsOptions, getLoginListBrickRoadIndicator} from '@libs/UserUtils'; -import {formatVacationDelegateClearDateTime, getVacationDelegateClearDateTime, isVacationDelegateExpired} from '@libs/VacationDelegateUtils'; +import {formatVacationDelegateClearDateTime, getActiveVacationDelegate, getVacationDelegateClearDateTime} from '@libs/VacationDelegateUtils'; import useTimeSensitiveHomeAddress from '@pages/home/TimeSensitiveSection/hooks/useTimeSensitiveHomeAddress'; @@ -104,11 +104,14 @@ function ProfilePage() { const [commuterExclusionsWorkspaceName] = useOnyx(ONYXKEYS.COLLECTION.POLICY, {selector: homeAndOfficeCommuterExclusionPolicyNameSelector}); const [vacationDelegate] = useOnyx(ONYXKEYS.NVP_PRIVATE_VACATION_DELEGATE); - const hasVacationDelegate = !!vacationDelegate?.delegate && !isVacationDelegateExpired(vacationDelegate?.clearAfter); - const vacationDelegatePersonalDetails = useVacationDelegatePersonalDetails(hasVacationDelegate ? vacationDelegate?.delegate : undefined); + const activeVacationDelegate = getActiveVacationDelegate(vacationDelegate); + const hasVacationDelegate = !!activeVacationDelegate; + const vacationDelegatePersonalDetails = useVacationDelegatePersonalDetails(activeVacationDelegate); const vacationDelegateName = hasVacationDelegate - ? getVacationDelegateDisplayName(vacationDelegatePersonalDetails?.login ?? vacationDelegate?.delegate ?? '', vacationDelegatePersonalDetails?.displayName, formatPhoneNumber) + ? getVacationDelegateDisplayName(vacationDelegatePersonalDetails?.login ?? activeVacationDelegate, vacationDelegatePersonalDetails?.displayName, formatPhoneNumber) : ''; + // With no delegate there is nothing to show on the form yet, so go straight to picking one. + const vacationDelegateRoute = hasVacationDelegate ? ROUTES.SETTINGS_VACATION_DELEGATE : ROUTES.SETTINGS_VACATION_DELEGATE_SELECT; const vacationDelegateClearDate = hasVacationDelegate ? formatVacationDelegateClearDateTime(getVacationDelegateClearDateTime(vacationDelegate?.clearAfter, currentUserPersonalDetails?.timezone?.selected), dateFnsLocale) : ''; @@ -165,17 +168,16 @@ function ProfilePage() { displayName={vacationDelegateName} avatar={vacationDelegatePersonalDetails?.avatar} accountID={vacationDelegatePersonalDetails?.accountID} - login={vacationDelegatePersonalDetails?.login ?? vacationDelegate?.delegate} + login={vacationDelegatePersonalDetails?.login ?? activeVacationDelegate} untilText={vacationDelegateUntilText} brickRoadIndicator={vacationDelegateBrickRoadIndicator} /> ) : undefined, accessibilityLabel: hasVacationDelegate ? [translate('common.vacationDelegate'), vacationDelegateName, vacationDelegateUntilText].filter(Boolean).join(', ') : undefined, - // With no delegate there is nothing to show on the form yet, so go straight to picking one. - pageRoute: hasVacationDelegate ? ROUTES.SETTINGS_VACATION_DELEGATE : ROUTES.SETTINGS_VACATION_DELEGATE_SELECT, + pageRoute: vacationDelegateRoute, onPress: () => { clearDraftValues(ONYXKEYS.FORMS.VACATION_DELEGATE_FORM); - Navigation.navigate(hasVacationDelegate ? ROUTES.SETTINGS_VACATION_DELEGATE : ROUTES.SETTINGS_VACATION_DELEGATE_SELECT); + Navigation.navigate(vacationDelegateRoute); }, brickRoadIndicator: vacationDelegateBrickRoadIndicator, testID: 'vacation-delegate-menu-item', diff --git a/tests/ui/DomainMemberVacationDelegateFormPageTest.tsx b/tests/ui/DomainMemberVacationDelegateFormPageTest.tsx new file mode 100644 index 000000000000..c4b787870f61 --- /dev/null +++ b/tests/ui/DomainMemberVacationDelegateFormPageTest.tsx @@ -0,0 +1,98 @@ +import {render} from '@testing-library/react-native'; + +import DelegatorList from '@components/DelegatorList'; +import VacationDelegateForm from '@components/VacationDelegateForm'; + +import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; +import type {SettingsNavigatorParamList} from '@libs/Navigation/types'; + +import DomainMemberVacationDelegateFormPage from '@pages/domain/Members/DomainMemberVacationDelegateFormPage'; + +import SCREENS from '@src/SCREENS'; +import type {PersonalDetails} from '@src/types/onyx'; +import type {BaseVacationDelegate} from '@src/types/onyx/VacationDelegate'; + +import type {PropsWithChildren} from 'react'; + +import React from 'react'; + +const DOMAIN_ACCOUNT_ID = 1; +const MEMBER_ACCOUNT_ID = 2; +const MEMBER_EMAIL = 'member@example.com'; +const MEMBER_TIMEZONE = 'Asia/Tokyo'; + +let mockVacationDelegate: BaseVacationDelegate | undefined; +let mockMemberPersonalDetails: PersonalDetails | undefined; + +jest.mock('@libs/Navigation/Navigation', () => ({ + navigate: jest.fn(), + goBack: jest.fn(), +})); + +jest.mock('@components/ScreenWrapper', () => jest.fn(({children}: PropsWithChildren) => children)); +jest.mock('@components/Header/composed/HeaderWithBackButtonAndTitle', () => jest.fn(() => null)); +jest.mock('@pages/domain/DomainNotFoundPageWrapper', () => jest.fn(({children}: PropsWithChildren) => children)); +jest.mock('@components/VacationDelegateForm', () => jest.fn(() => null)); +jest.mock('@components/DelegatorList', () => jest.fn(() => null)); + +jest.mock('@hooks/useThemeStyles', () => jest.fn(() => new Proxy({}, {get: () => ({})}))); +jest.mock('@hooks/useLocalize', () => jest.fn(() => ({translate: (key: string) => key}))); +jest.mock('@hooks/useCurrentUserPersonalDetails', () => jest.fn(() => ({login: 'admin@example.com', timezone: {selected: 'America/Los_Angeles'}}))); +jest.mock('@hooks/useOnyx', () => jest.fn(() => [mockVacationDelegate])); +jest.mock('@hooks/usePersonalDetails', () => ({usePersonalDetail: jest.fn(() => [mockMemberPersonalDetails])})); + +type DomainMemberVacationDelegateFormPageProps = PlatformStackScreenProps; + +const route: DomainMemberVacationDelegateFormPageProps['route'] = { + key: 'domain-vacation-delegate', + name: SCREENS.DOMAIN.VACATION_DELEGATE, + params: {domainAccountID: DOMAIN_ACCOUNT_ID, accountID: MEMBER_ACCOUNT_ID}, +}; +// The screen does not read navigation; this inert test double only satisfies the navigator-provided prop. +// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion +const navigation = {} as DomainMemberVacationDelegateFormPageProps['navigation']; + +function renderPage() { + return render( + , + ); +} + +describe('DomainMemberVacationDelegateFormPage', () => { + const mockedVacationDelegateForm = jest.mocked(VacationDelegateForm); + const mockedDelegatorList = jest.mocked(DelegatorList); + + beforeEach(() => { + jest.clearAllMocks(); + mockVacationDelegate = {creator: 'admin@example.com', delegate: 'delegate@example.com'}; + mockMemberPersonalDetails = {accountID: MEMBER_ACCOUNT_ID, login: MEMBER_EMAIL, timezone: {selected: MEMBER_TIMEZONE}}; + }); + + it('picks the clear date in the member’s timezone, not the admin’s', () => { + // Given an admin in Los Angeles editing the delegate of a member whose Profile timezone is Tokyo + // When the form page opens + renderPage(); + + // Then the form reads and builds the clear date in the member's timezone, so the delegate clears at the end of the vacationer's day + expect(mockedVacationDelegateForm.mock.lastCall?.[0]).toEqual(expect.objectContaining({timezone: MEMBER_TIMEZONE})); + expect(mockedDelegatorList).not.toHaveBeenCalled(); + }); + + it('shows the delegator list instead of the form while the member is someone else’s delegate', () => { + // Given a member with a saved delegate, who is also the vacation delegate for another member + mockVacationDelegate = {creator: 'admin@example.com', delegate: 'delegate@example.com', delegatorFor: ['other@example.com']}; + + // When the form page opens + renderPage(); + + // Then the admin can't change the date or remove the delegate, same as on the member picker + expect(mockedVacationDelegateForm).not.toHaveBeenCalled(); + expect(mockedDelegatorList.mock.lastCall?.[0]).toEqual({ + delegators: ['other@example.com'], + message: 'domain.members.cannotSetVacationDelegateForMember', + }); + }); +}); diff --git a/tests/unit/components/VacationDelegateMenuItemTest.tsx b/tests/unit/components/VacationDelegateMenuItemTest.tsx index eb4c118a4b86..baf0caa917f8 100644 --- a/tests/unit/components/VacationDelegateMenuItemTest.tsx +++ b/tests/unit/components/VacationDelegateMenuItemTest.tsx @@ -256,9 +256,10 @@ describe('VacationDelegateMenuItem', () => { />, ); - // Then it is not shown as an active delegate, since approvals no longer go to them + // Then it is not shown as an active delegate, since approvals no longer go to them, and its personal details aren't looked up for a row that hides them expect(screen.queryByText('Jane Doe')).toBeNull(); expect(screen.getByText('common.vacationDelegate')).toBeTruthy(); + expect(mockUseVacationDelegatePersonalDetails).toHaveBeenLastCalledWith(undefined); }); it('shows when the delegate clears', () => { diff --git a/tests/unit/libs/VacationDelegateUtilsTest.ts b/tests/unit/libs/VacationDelegateUtilsTest.ts index 0d82e21570ca..006e14e29da2 100644 --- a/tests/unit/libs/VacationDelegateUtilsTest.ts +++ b/tests/unit/libs/VacationDelegateUtilsTest.ts @@ -1,5 +1,6 @@ import { formatVacationDelegateClearDateTime, + getActiveVacationDelegate, getVacationDelegateClearAfter, getVacationDelegateClearDate, getVacationDelegateClearDateTime, @@ -99,6 +100,18 @@ describe('VacationDelegateUtils', () => { }); }); + describe('getActiveVacationDelegate', () => { + it('returns the delegate only while it is still in effect', () => { + // Given the current time is 2026-09-28 12:00 UTC + // When a delegate that clears later, one that never clears, one that already cleared, and no delegate are checked + // Then only the first two are still in effect, so every screen hides an expired delegate the same way + expect(getActiveVacationDelegate({delegate: 'jane@example.com', clearAfter: '2026-09-28 12:00:01'})).toBe('jane@example.com'); + expect(getActiveVacationDelegate({delegate: 'jane@example.com'})).toBe('jane@example.com'); + expect(getActiveVacationDelegate({delegate: 'jane@example.com', clearAfter: '2026-09-28 11:59:59'})).toBeUndefined(); + expect(getActiveVacationDelegate(undefined)).toBeUndefined(); + }); + }); + describe('isVacationDelegateClearDatePassed', () => { it('reads the clear date in the Profile timezone, not the device timezone', () => { // Given it is 2026-10-08 03:00 UTC: still Oct 7 in Los Angeles, already Oct 8 in Tokyo, and Oct 8 on the UTC test device From b4c2607c783078a4cac70255983b179eaf1e6480 Mon Sep 17 00:00:00 2001 From: "Kevin Brian Bader (via MelvinBot)" Date: Fri, 9 Oct 2026 21:46:53 +0000 Subject: [PATCH 09/12] Address review on vacation delegates: page-driven Save spinner, queued date-only saves, offline greying, and dropping superseded sets on removal Co-authored-by: Kevin Brian Bader --- src/components/VacationDelegateForm.tsx | 89 ++++++++++++------- src/libs/actions/RequestConflictUtils.ts | 31 +++++++ src/libs/actions/VacationDelegate.ts | 10 ++- .../DomainMemberVacationDelegatePage.tsx | 5 ++ .../CustomStatus/VacationDelegateFormPage.tsx | 29 ++++-- src/pages/settings/Profile/ProfilePage.tsx | 48 ++++++---- tests/ui/VacationDelegateFormPageTest.tsx | 66 +++++++++++++- tests/unit/RequestConflictUtilsTest.ts | 29 ++++++ 8 files changed, 249 insertions(+), 58 deletions(-) diff --git a/src/components/VacationDelegateForm.tsx b/src/components/VacationDelegateForm.tsx index 006c5595d8da..4ad6b8cbe6de 100644 --- a/src/components/VacationDelegateForm.tsx +++ b/src/components/VacationDelegateForm.tsx @@ -1,4 +1,5 @@ import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; +import useInitialSelection from '@hooks/useInitialSelection'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; @@ -31,6 +32,7 @@ import DatePicker from './DatePicker'; import FormProvider from './Form/FormProvider'; import InputWrapper from './Form/InputWrapper'; import MenuItemAction from './MenuItem/presets/MenuItemAction'; +import OfflineWithFeedback from './OfflineWithFeedback'; import Text from './Text'; import TimeModalPicker from './TimeModalPicker'; import VacationDelegateMenuItem from './VacationDelegateMenuItem'; @@ -69,9 +71,23 @@ type VacationDelegateFormProps = { /** Dismisses `errors` */ onCloseError?: () => void; + + /** Whether the save request is still running, which shows the spinner on the Save button */ + isLoading?: boolean; }; -function VacationDelegateForm({vacationDelegate, description, timezone: timezoneProp, onChangeDelegate, onSubmit, onRemove, errors, pendingAction, onCloseError}: VacationDelegateFormProps) { +function VacationDelegateForm({ + vacationDelegate, + description, + timezone: timezoneProp, + onChangeDelegate, + onSubmit, + onRemove, + errors, + pendingAction, + onCloseError, + isLoading = false, +}: VacationDelegateFormProps) { const styles = useThemeStyles(); const {translate, dateFnsLocale, getLocalDateFromDatetime} = useLocalize(); const icons = useMemoizedLazyExpensifyIcons(['CalendarSolid', 'Trashcan']); @@ -80,6 +96,9 @@ function VacationDelegateForm({vacationDelegate, description, timezone: timezone const [draftValues] = useOnyx(ONYXKEYS.FORMS.VACATION_DELEGATE_FORM_DRAFT); const savedDelegate = getActiveVacationDelegate(vacationDelegate); + // Remove reflects the delegate saved when the form opened. Otherwise the optimistic write from this form's own Save + // would make the button appear while the request runs and the RHP closes. + const savedDelegateOnOpen = useInitialSelection(savedDelegate); const savedClearDateTime = savedDelegate ? getVacationDelegateClearDateTime(vacationDelegate?.clearAfter, timezone) : ''; const savedClearDate = savedDelegate ? getVacationDelegateClearDate(vacationDelegate?.clearAfter, timezone) : ''; // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing -- an empty draft means no member was picked yet @@ -122,6 +141,10 @@ function VacationDelegateForm({vacationDelegate, description, timezone: timezone submitButtonText={translate('common.save')} submitButtonStyles={styles.ph5} isSubmitDisabled={!delegate} + isLoading={isLoading} + // The press spinner only resets when the screen regains focus, which the error modal never takes away, so it would stay on + // after a failed save. The page drives the spinner through isLoading instead. + shouldShowLoadingImmediatelyOnPress={false} enabledWhenOffline shouldHideFixErrorsAlert > @@ -134,39 +157,45 @@ function VacationDelegateForm({vacationDelegate, description, timezone: timezone onCloseError={onCloseError} onPress={onChangeDelegate} /> - - {/* The date can only be changed through the picker, so it keeps its icon and has no clear button, as in the mockups. - The earliest day is today in the form's timezone, so it matches the validation even when the device is in another timezone. */} - - - {/* The time only means something once a day is picked. Until it is changed, the delegate clears at the end of that day. */} - {!!clearDate && ( - + {/* The clear after date and time belong to the same pending save as the delegate, so they grey out with it */} + + + {/* The date can only be changed through the picker, so it keeps its icon and has no clear button, as in the mockups. + The earliest day is today in the form's timezone, so it matches the validation even when the device is in another timezone. */} - )} - {/* The bottom margin matches the date input's own, so the remove button below sits the same distance from whichever is last */} - {!!formattedClearDateTime && ( - {translate('statusPage.vacationDelegate.willClearOn', formattedClearDateTime)} - )} - {!!savedDelegate && ( + {/* The time only means something once a day is picked. Until it is changed, the delegate clears at the end of that day. */} + {!!clearDate && ( + + + + )} + {/* The bottom margin matches the date input's own, so the remove button below sits the same distance from whichever is last */} + {!!formattedClearDateTime && ( + {translate('statusPage.vacationDelegate.willClearOn', formattedClearDateTime)} + )} + + {!!savedDelegateOnOpen && !!savedDelegate && ( ( }; } +/** + * Removing the vacation delegate supersedes any change to it still waiting in the queue, e.g. a clear after date changed offline + * before the removal. Sending that change first would let its response bring the removed delegate back until the removal lands. + * Only the current user's own delegate is matched, so a domain admin's change for a member is left alone. + */ +function resolveVacationDelegateDeletionConflicts(persistedRequests: Array>): ConflictActionData { + const indicesToDelete = persistedRequests.reduce((indices, request, index) => { + if (request.command === WRITE_COMMANDS.SET_VACATION_DELEGATE && !request.data?.domainAccountID) { + indices.push(index); + } + return indices; + }, []); + + if (indicesToDelete.length === 0) { + return { + conflictAction: { + type: 'push', + }, + }; + } + + return { + conflictAction: { + type: 'delete', + indices: indicesToDelete, + pushNewRequest: true, + }, + }; +} + export { resolveDuplicationConflictAction, resolveOpenAppDuplicationConflictAction, @@ -425,6 +455,7 @@ export { resolveEnableFeatureConflicts, enablePolicyFeatureCommand, resolveDetachReceiptConflicts, + resolveVacationDelegateDeletionConflicts, }; export type {EnablePolicyFeatureCommand, AnyRequestMatcher}; diff --git a/src/libs/actions/VacationDelegate.ts b/src/libs/actions/VacationDelegate.ts index cb2c8eb38727..50caf49c90f6 100644 --- a/src/libs/actions/VacationDelegate.ts +++ b/src/libs/actions/VacationDelegate.ts @@ -23,6 +23,7 @@ import Onyx from 'react-native-onyx'; import type {CurrentUser} from './Policy/Policy'; import {addMembersToWorkspace} from './Policy/Member'; +import {resolveVacationDelegateDeletionConflicts} from './RequestConflictUtils'; type SetVacationDelegateOptions = { creator: string; @@ -181,7 +182,14 @@ function deleteVacationDelegate(vacationDelegate?: VacationDelegate) { }, ]; - API.write(WRITE_COMMANDS.DELETE_VACATION_DELEGATE, null, {optimisticData, successData, failureData}); + API.write( + WRITE_COMMANDS.DELETE_VACATION_DELEGATE, + null, + {optimisticData, successData, failureData}, + { + checkAndFixConflictingRequest: (persistedRequests) => resolveVacationDelegateDeletionConflicts(persistedRequests), + }, + ); } function clearVacationDelegateError(previousDelegate?: string, previousClearAfter?: string) { diff --git a/src/pages/domain/Members/DomainMemberVacationDelegatePage.tsx b/src/pages/domain/Members/DomainMemberVacationDelegatePage.tsx index 2177c2e531fc..d6f12b826966 100644 --- a/src/pages/domain/Members/DomainMemberVacationDelegatePage.tsx +++ b/src/pages/domain/Members/DomainMemberVacationDelegatePage.tsx @@ -1,6 +1,7 @@ import BaseVacationDelegateSelectionComponent from '@components/BaseVacationDelegateSelectionComponent'; import ScreenWrapper from '@components/ScreenWrapper'; +import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; import {usePersonalDetail} from '@hooks/usePersonalDetails'; @@ -33,6 +34,7 @@ const draftDelegateSelector = (draft: OnyxEntry) => draft? function DomainMemberVacationDelegatePage({route}: DomainMemberVacationDelegatePageProps) { const {domainAccountID, accountID} = route.params; const {translate} = useLocalize(); + const {login: currentUserLogin} = useCurrentUserPersonalDetails(); const [vacationDelegate] = useOnyx(`${ONYXKEYS.COLLECTION.DOMAIN}${domainAccountID}`, { selector: vacationDelegateSelector(accountID), @@ -75,6 +77,9 @@ function DomainMemberVacationDelegatePage({route}: DomainMemberVacationDelegateP onBackButtonPress={() => Navigation.goBack()} cannotSetDelegateMessage={translate('domain.members.cannotSetVacationDelegateForMember', memberLogin ?? '')} additionalExcludeLogins={memberLogin ? {[memberLogin]: true} : undefined} + // The member can't be their own delegate. The current user is included by default, and that would override the + // member exclusion above when an admin opens their own member page. + includeCurrentUser={memberLogin !== currentUserLogin} /> diff --git a/src/pages/settings/Profile/CustomStatus/VacationDelegateFormPage.tsx b/src/pages/settings/Profile/CustomStatus/VacationDelegateFormPage.tsx index e1fb8b2de6d0..c0ffa0484964 100644 --- a/src/pages/settings/Profile/CustomStatus/VacationDelegateFormPage.tsx +++ b/src/pages/settings/Profile/CustomStatus/VacationDelegateFormPage.tsx @@ -20,7 +20,7 @@ import ROUTES from '@src/ROUTES'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; import {useNavigation} from '@react-navigation/native'; -import React, {useRef} from 'react'; +import React, {useRef, useState} from 'react'; import {View} from 'react-native'; function goBackToProfile() { @@ -45,6 +45,8 @@ function VacationDelegateFormPage() { const hasActiveDelegations = !!vacationDelegate?.delegatorFor?.length; const isSubmittingRef = useRef(false); + // Drives the Save spinner and stays on until the error modal is dismissed, so the modal is the only place a save error shows + const [isSaving, setIsSaving] = useState(false); const showErrorModal = async (delegateToRestore?: string, clearAfterToRestore?: string, message?: string) => { await showConfirmModal({ @@ -62,12 +64,22 @@ function VacationDelegateFormPage() { return; } - isSubmittingRef.current = true; const hasUnconfirmedChange = !!vacationDelegate?.pendingAction || !isEmptyObject(vacationDelegate?.errors) || !!vacationDelegate?.policyDiff; const currentDelegate = hasUnconfirmedChange ? vacationDelegate?.previousDelegate : vacationDelegate?.delegate; const currentClearAfter = hasUnconfirmedChange ? vacationDelegate?.previousClearAfter : vacationDelegate?.clearAfter; + + // Only the clear after date or time changed. The delegate was already accepted, so there's no policy diff to read, and a queued + // write keeps the change optimistic offline instead of failing a one-off request. + if (delegate === vacationDelegate?.delegate) { + setVacationDelegate({creator: currentUserLogin, delegate, clearAfter, currentDelegate, currentClearAfter, shouldOverridePolicyDiffWarning: true}); + goBackToProfile(); + return; + } + + isSubmittingRef.current = true; + setIsSaving(true); setVacationDelegate({creator: currentUserLogin, delegate, clearAfter, currentDelegate, currentClearAfter}) - .then((response) => { + .then(async (response) => { if (!navigation.isFocused()) { if (response?.data?.policyDiff) { clearVacationDelegateError(currentDelegate, currentClearAfter); @@ -83,22 +95,24 @@ function VacationDelegateFormPage() { // The action leaves the failure on the NVP for the profile page's red brick road, but the user is still on this screen, // so report it where they are. Dismissing the modal restores the previous delegate, exactly as dismissing that error would. if (response?.jsonCode !== CONST.JSON_CODE.SUCCESS) { - showErrorModal(currentDelegate, currentClearAfter, response?.jsonCode === CONST.JSON_CODE.EXP_ERROR ? response.message : undefined); + // Awaited so the spinner and the hidden inline error last until the modal is dismissed + await showErrorModal(currentDelegate, currentClearAfter, response?.jsonCode === CONST.JSON_CODE.EXP_ERROR ? response.message : undefined); return; } goBackToProfile(); }) - .catch(() => { + .catch(async () => { if (!navigation.isFocused()) { clearVacationDelegateError(currentDelegate, currentClearAfter); return; } - showErrorModal(currentDelegate, currentClearAfter); + await showErrorModal(currentDelegate, currentClearAfter); }) .finally(() => { isSubmittingRef.current = false; + setIsSaving(false); }); }; @@ -132,8 +146,9 @@ function VacationDelegateFormPage() { onChangeDelegate={() => Navigation.navigate(ROUTES.SETTINGS_VACATION_DELEGATE_SELECT)} onSubmit={onSubmit} onRemove={onRemove} - errors={getVacationDelegateErrors(vacationDelegate)} + errors={isSaving ? undefined : getVacationDelegateErrors(vacationDelegate)} pendingAction={vacationDelegate?.pendingAction} + isLoading={isSaving} onCloseError={() => clearVacationDelegateError(vacationDelegate?.previousDelegate, vacationDelegate?.previousClearAfter)} /> )} diff --git a/src/pages/settings/Profile/ProfilePage.tsx b/src/pages/settings/Profile/ProfilePage.tsx index 63505fd81324..b612fdd7be8e 100755 --- a/src/pages/settings/Profile/ProfilePage.tsx +++ b/src/pages/settings/Profile/ProfilePage.tsx @@ -54,6 +54,7 @@ import ROUTES, {DYNAMIC_ROUTES} from '@src/ROUTES'; import type {Route} from '@src/ROUTES'; import type SCREENS from '@src/SCREENS'; import INPUT_IDS from '@src/types/form/PersonalDetailsForm'; +import type {PendingAction} from '@src/types/onyx/OnyxCommon'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; import type {ComponentRef, ReactNode} from 'react'; @@ -133,6 +134,9 @@ function ProfilePage() { /** Accessibility label for a row whose `content` is not plain text */ accessibilityLabel?: string; + + /** Greys the row out while a change to its value is waiting for the network */ + pendingAction?: PendingAction; }> = [ { description: translate('displayNamePage.headerTitle'), @@ -180,6 +184,7 @@ function ProfilePage() { Navigation.navigate(vacationDelegateRoute); }, brickRoadIndicator: vacationDelegateBrickRoadIndicator, + pendingAction: vacationDelegate?.pendingAction ?? undefined, testID: 'vacation-delegate-menu-item', sentryLabel: CONST.SENTRY_LABEL.SETTINGS_PROFILE.VACATION_DELEGATE, }, @@ -325,27 +330,32 @@ function ProfilePage() { {publicOptions.map((detail) => { const {pageRoute} = detail; return ( - Navigation.navigate(pageRoute) : undefined)} - testID={detail?.testID} - sentryLabel={detail.sentryLabel} - accessibilityLabel={detail.accessibilityLabel} + pendingAction={detail.pendingAction} + shouldHideOnDelete={false} > - {detail.content ?? ( - - {(!!detail.brickRoadIndicator || !!pageRoute) && ( - <> - {!!detail.brickRoadIndicator && } - {!!pageRoute && } - - )} - - )} - + Navigation.navigate(pageRoute) : undefined)} + testID={detail?.testID} + sentryLabel={detail.sentryLabel} + accessibilityLabel={detail.accessibilityLabel} + > + {detail.content ?? ( + + {(!!detail.brickRoadIndicator || !!pageRoute) && ( + <> + {!!detail.brickRoadIndicator && } + {!!pageRoute && } + + )} + + )} + + ); })}