Skip to content

Add Send to my employer option to expense More menu - #99247

Merged
francoisl merged 2 commits into
mainfrom
shawn-lookingAround
Aug 25, 2026
Merged

francoisl merged 2 commits into
mainfrom
shawn-lookingAround

Conversation

@shawnborton

@shawnborton shawnborton commented Aug 22, 2026 •

Copy link
Copy Markdown
Contributor

Explanation of Change

When a "Looking Around" user (someone who chose the "Something else" onboarding intent) creates an expense, they land on the Spend page. Opening the expense and its More menu previously offered only Send to someone — but the personal-space Concierge in Inbox offers two options: Send to someone and Submit to my employer.

This PR adds a Submit to my employer option to the expense More menu, reusing the exact same flow as the Inbox Concierge "Submit to my employer" action. Both call createDraftTransactionAndNavigateToParticipantSelector with actionName: SUBMIT and submitDestination: EMPLOYER, so the destination resolves identically:

  • No accessible workspace: a new Submit workspace is created and the expense drops into its draft confirmation screen.
  • Exactly one workspace: skips the picker and goes straight to that workspace's confirmation screen.
  • Multiple workspaces: opens the workspaces-only destination picker.

The new row is gated on the same condition as "Send to someone" (an unreported self-tracked expense in personal space, with write access), and sits in the same top section of the menu.

Fixed Issues

$ #97881
PROPOSAL:

Tests

  1. Sign up as a new user and select the Something else onboarding intent (this qualifies you as a "Looking Around" user).
  2. Create an expense — you land on the Spend page.
  3. Open the expense, then open the More menu.
  4. Verify a Submit to my employer option appears in the top section, alongside Send to someone.
  5. Tap Submit to my employer and verify it behaves the same as the Inbox Concierge "Submit to my employer" option:
    • With no workspace: a new Submit workspace is created and you land on the confirmation screen.
    • With exactly one workspace: you go straight to that workspace's confirmation screen.
    • With multiple workspaces: the workspaces-only destination picker opens.
  6. Open the same expense from the Inbox personal space via Concierge and confirm the "Submit to my employer" outcome matches the More-menu one.
  • Verify that no errors appear in the JS console

Offline tests

  1. Go offline.
  2. Open a self-tracked expense's More menu and verify Submit to my employer still appears (it is built from local Onyx state).
  3. Select it and verify the flow proceeds and the request is queued, matching the Inbox behavior offline.

QA Steps

Same as tests.

  • Verify that no errors appear in the JS console

PR Author Checklist

  • I linked the correct issue in the ### Fixed Issues section above
  • I wrote clear testing steps that cover the changes made in this PR
    • I added steps for local testing in the Tests section
    • I added steps for the expected offline behavior in the Offline steps section
    • I added steps for Staging and/or Production testing in the QA steps section
    • I added steps to cover failure scenarios (i.e. verify an input displays the correct error message if the entered data is not correct)
    • I turned off my network connection and tested it while offline to ensure it matches the expected behavior (i.e. verify the default avatar icon is displayed if app is offline)
    • I tested this PR with a High Traffic account against the staging or production API to ensure there are no regressions (e.g. long loading states that impact usability).
  • I included screenshots or videos for tests on all platforms
  • I ran the tests on all platforms & verified they passed on:
    • Android: Native
    • Android: mWeb Chrome
    • iOS: Native
    • iOS: mWeb Safari
    • MacOS: Chrome / Safari
  • I verified there are no console errors (if there's a console error not related to the PR, report it or open an issue for it to be fixed)
  • I followed proper code patterns (see Reviewing the code)
    • I verified that comments were added to code that is not self explanatory
    • I verified that any new or modified comments were clear, correct English, and explained "why" the code was doing something instead of only explaining "what" the code was doing.
    • I verified any copy / text that was added to the app is grammatically correct in English. It adheres to proper capitalization guidelines (note: only the first word of header/labels should be capitalized), and is either coming verbatim from figma or has been approved by marketing (in order to get marketing approval, ask the Bug Zero team member to add the Waiting for copy label to the issue)
  • If a new code pattern is added I verified it was agreed to be used by multiple Expensify engineers
  • I followed the guidelines as stated in the Review Guidelines
  • I tested other components that can be impacted by my changes (i.e. if the PR modifies a shared library or component like Avatar, I verified the components using Avatar are working as expected)
  • If a new CSS style is added I verified that:
    • A similar style doesn't already exist
    • The style can't be created with an existing StyleUtils function (i.e. StyleUtils.getBackgroundAndBorderStyle(theme.componentBG))
  • If new assets were added or existing ones were modified, I verified that:
    • The assets are optimized and compressed (for SVG files, run npm run compress-svg)
    • The assets load correctly across all supported platforms.
  • If the PR modifies code that runs when editing or sending messages, I tested and verified there is no unexpected behavior for all supported markdown - URLs, single line code, code blocks, quotes, headings, bold, strikethrough, and italic.
  • If the PR modifies a generic component, I tested and verified that those changes do not break usages of that component in the rest of the App (i.e. if a shared library or component like Avatar is modified, I verified that Avatar is working as expected in all cases)
  • If the PR modifies a component related to any of the existing Storybook stories, I tested and verified all stories for that component are still working as expected.
  • If the PR modifies a component or page that can be accessed by a direct deeplink, I verified that the code functions as expected when the deeplink is used - from a logged in and logged out account.
  • If the PR modifies the UI (e.g. new buttons, new UI components, changing the padding/spacing/sizing, moving components, etc) or modifies the form input styles:
    • I verified that all the inputs inside a form are aligned with each other.
    • I added Design label and/or tagged @Expensify/design so the design team can review the changes.
  • I added unit tests for any new feature or bug fix in this PR to help automatically prevent regressions in this user flow.
  • If the main branch was merged into this PR after a review, I tested again and verified the outcome was still expected according to the Test steps.

Screenshots/Videos

Android: Native
Android: mWeb Chrome
iOS: Native
iOS: mWeb Safari
MacOS: Chrome / Safari

@shawnborton
shawnborton requested review from a team as code owners August 22, 2026 13:29
@melvin-bot
melvin-bot Bot requested review from heyjennahay and ikevin127 and removed request for a team August 22, 2026 13:29
@melvin-bot

melvin-bot Bot commented Aug 22, 2026

Copy link
Copy Markdown

@ikevin127 Please copy/paste the Reviewer Checklist from here into a new comment on this PR and complete it. If you have the K2 extension, you can simply click: [this button]

@OSBotify

This comment has been minimized.

@codecov

codecov Bot commented Aug 22, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Looks like you've decreased code coverage for some files. Please write tests to increase, or at least maintain, the existing level of code coverage. See our documentation here for how to interpret this table.

Files with missing lines Coverage Δ
src/CONST/index.ts 94.85% <ø> (ø)
src/hooks/useLastWorkspaceNumber.ts 100.00% <100.00%> (ø)
src/libs/PopoverMenuSections.ts 100.00% <ø> (ø)
src/libs/ReportSecondaryActionUtils.ts 95.82% <100.00%> (+<0.01%) ⬆️
.../components/MoneyRequestHeaderSecondaryActions.tsx 0.45% <0.00%> (-0.01%) ⬇️
... and 55 files with indirect coverage changes

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: e4d68e3c6a

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

firstPolicyID: filteredPoliciesInfo?.firstPolicyID,
...sendTrackedExpenseParams,
submitDestination: CONST.IOU.SUBMIT_DESTINATION.EMPLOYER,
defaultWorkspaceName: generateDefaultWorkspaceName(currentUserLogin ?? '', lastWorkspaceNumber, translate, currentUserDisplayName),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Use the session email for the employer flow

When the session is available but the current user's PERSONAL_DETAILS_LIST entry has not loaded, CurrentUserPersonalDetailsProvider still supplies email while login is undefined. In that state this call generates an empty workspace name, and sendTrackedExpenseParams also passes an empty currentUserEmail and a policy count computed from the missing login; selecting this option can therefore create a duplicate workspace with malformed optimistic owner/member data instead of using an existing workspace. Read email from useCurrentUserPersonalDetails() and use it as the fallback for the count and employer-flow parameters, as the Inbox and report-details implementations do.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Confirmed, this needs fixing before merge!

The premise holds: CurrentUserPersonalDetailsProvider.tsx:23-31 sources email from SESSION but spreads login from PERSONAL_DETAILS_LIST, so there is a real window where the user is signed in, email is set, and login is still undefined.

In that window generateDefaultWorkspaceName(currentUserLogin ?? '', ...) hits the emailParts?.length !== 2 guard at Policy.ts:2450 and returns '', and currentUserEmail: currentUserLogin ?? '' flows through createDraftWorkspace into an optimistic policy with name: '', owner: '', approver: '' and employeeList: {'': {...}} (Policy.ts:3306+). That is a malformed workspace that gets sent to the server, not just a cosmetic blank.

The reason this belongs in this PR rather than a follow-up, even though two of the three currentUserLogin uses are pre-existing: ReportUtils.ts:12174 gates the workspace-creation branch on submitDestination === EMPLOYER, and Send to someone leaves it at the FRIEND default. Nothing in this component could reach createDraftWorkspaceAndNavigateToConfirmationScreen before. Adding Send to my employer is what makes the path reachable, so the latent inconsistency becomes a live bug in this diff.

generateDefaultWorkspaceName has ten call sites and MoneyRequestHeaderSecondaryActions.tsx:678 is the only one passing login. DynamicReportDetailsPage.tsx:245,568,575 runs this exact employer flow on email with the same four-argument shape, so switching to email just matches existing precedent.

One correction on the report: the policy-count part is overstated. getPolicyRole returns policy.role before it ever checks login (PolicyUtils.ts:632-634), so for policies where the server populated role the count is still correct. The count only collapses for policies falling through to the employeeList lookup. The malformed-data outcome is the unconditional one and is what makes this worth blocking on.

useLastWorkspaceNumber() needs no change, it already falls back to the session email internally.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed and fixed in 02c61cc. The employer flow now reads email from useCurrentUserPersonalDetails() (which always comes from SESSION) instead of login, so generateDefaultWorkspaceName and currentUserEmail no longer collapse to '' during the window where login is still undefined. I also switched createFilteredPoliciesInfoSelector to email to match the Inbox/report-details precedent. Left useLastWorkspaceNumber() untouched since it already falls back to the session email internally. Thanks for the getPolicyRole correction on the count — agreed the malformed-workspace outcome was the real blocker.

Comment thread src/libs/ReportSecondaryActionUtils.ts Outdated
Comment on lines 1261 to 1268
if (
isTrackExpenseReportNew(transactionThreadReport, parentReport, reportAction) &&
(!isSelfDMExpenseSplit || hasWorkspaceToSubmitTo) &&
canUserPerformWriteActionReportUtils(parentReport, isChatReportArchived)
) {
options.push(CONST.REPORT.TRANSACTION_SECONDARY_ACTIONS.SEND_TO_SOMEONE);
options.push(CONST.REPORT.TRANSACTION_SECONDARY_ACTIONS.SEND_TO_EMPLOYER);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 src/libs/ReportSecondaryActionUtils.ts:1261-1268: SEND_TO_SOMEONE should not share the employer gate for splits

The PR claims parity with the Inbox Concierge flow. It does not have parity for one case, and the new comment now asserts the divergent behavior is intentional.

Here is what Inbox actually does (src/pages/inbox/report/actionContents/ChatActionableButtons.tsx:225-256):

const isSplitExpense = isSplitChildTransaction(trackExpenseTransaction);
const shouldShowSubmitButtons = !isSplitExpense || !!hasWorkspaceToSubmitTo;

{shouldShowSubmitButtons && (
    <>
        {!isSplitExpense && (
            <Button onPress={() => submit(CONST.IOU.SUBMIT_DESTINATION.FRIEND)}>…</Button>
        )}
        <Button onPress={() => submit(CONST.IOU.SUBMIT_DESTINATION.EMPLOYER)}>…</Button>
    </>
)}

Note the extra {!isSplitExpense && …} guard on the friend button. Inbox hides Submit to a friend for a split unconditionally, workspace or not.

The More menu pushes both under one gate:

if (
    isTrackExpenseReportNew(transactionThreadReport, parentReport, reportAction) &&
    (!isSelfDMExpenseSplit || hasWorkspaceToSubmitTo) &&
    canUserPerformWriteActionReportUtils(parentReport, isChatReportArchived)
) {
    options.push(CONST.REPORT.TRANSACTION_SECONDARY_ACTIONS.SEND_TO_SOMEONE);
    options.push(CONST.REPORT.TRANSACTION_SECONDARY_ACTIONS.SEND_TO_EMPLOYER);
}
case Inbox friend Inbox employer More menu SEND_TO_SOMEONE More menu SEND_TO_EMPLOYER
not a split shown shown shown shown
split, has workspace hidden shown shown shown
split, no workspace hidden hidden hidden hidden

The employer column matches exactly, which is the part this PR added, so the new code is right. The mismatch is on SEND_TO_SOMEONE, and it is pre-existing from #97883 rather than introduced here.

Two reasons I am still raising it on this PR:

  1. The comment rewrite actively codifies it as correct: "for a split we still surface both rows when the user has a workspace (the participant selector routes the split into it)". That is a new claim, and ChatActionableButtons says the opposite for the friend destination.
  2. The original gate reads as the employer rule applied to the friend row. "A self-DM split can only go to a workspace, so hide it for a split unless the user has one" is the reasoning for a workspace destination. Now that SEND_TO_EMPLOYER exists to cover the split case properly, the friend row no longer needs to borrow it.

Suggested split of the two conditions:

const {isExpenseSplit: isSelfDMExpenseSplit} = getOriginalTransactionWithSplitInfo(reportTransaction, originalTransaction);
const canConvertFromTrack = isTrackExpenseReportNew(transactionThreadReport, parentReport, reportAction) && canUserPerformWriteActionReportUtils(parentReport, isChatReportArchived);

if (canConvertFromTrack) {
    // A self-DM split has no personal destination, so it can never go to a friend (matches ChatActionableButtons).
    if (!isSelfDMExpenseSplit) {
        options.push(CONST.REPORT.TRANSACTION_SECONDARY_ACTIONS.SEND_TO_SOMEONE);
    }
    // A split can still go to a workspace, but only one that already exists: the create-a-workspace
    // fallback in createDraftTransactionAndNavigateToParticipantSelector is not wired for splits.
    if (!isSelfDMExpenseSplit || hasWorkspaceToSubmitTo) {
        options.push(CONST.REPORT.TRANSACTION_SECONDARY_ACTIONS.SEND_TO_EMPLOYER);
    }
}

Bug this causes today: a user with a self-DM split expense and at least one workspace opens More and sees Send to someone. Tapping it runs createDraftTransactionAndNavigateToParticipantSelector with submitDestination defaulting to FRIEND, which skips the employer branch at ReportUtils.ts:12174 and opens the people picker.

Inbox deliberately never offers that path for a split. Either the More menu is wrong or Inbox is, and right now they disagree.

If the issue / PR owner decides the current behavior is deliberate, please say so in the comment with the reason rather than the participant selector routes the split into it phrasing, because that is the claim I could not confirm anywhere in the flow.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 02c61cc. You're right — I could not confirm the "participant selector routes the split into it" claim either, and the FRIEND default just opens the people picker. Split the gate into two conditions: SEND_TO_SOMEONE now pushes only when !isSelfDMExpenseSplit (friend hidden for all splits, matching ChatActionableButtons), while SEND_TO_EMPLOYER keeps !isSelfDMExpenseSplit || hasWorkspaceToSubmitTo. Rewrote the comment to state the real reasons and dropped the incorrect claim.

const isTrackIntentUser = isTrackOnboardingChoice(introSelected?.choice);

const activePolicy = useActivePolicy();
const lastWorkspaceNumber = useLastWorkspaceNumber();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 src/components/MoneyRequestHeaderSecondaryActions.tsx:191: useLastWorkspaceNumber() puts a whole-POLICY-collection regex scan on every expense header render

This component is not lazy. MoneyRequestHeaderActions.tsx:50 renders it inline in the header for every expense, not on menu open. So the hook runs whenever an expense header renders.

What the hook costs (src/hooks/useLastWorkspaceNumber.ts):

function useLastWorkspaceNumber(email?: string) {
    const [sessionEmail] = useOnyx(ONYXKEYS.SESSION, {selector: emailSelector});
    const lastWorkspaceNumberSelectorWithEmail = (policies: OnyxCollection<Policy>) => lastWorkspaceNumberSelector(policies, email ?? sessionEmail ?? '');
    const [lastWorkspaceNumber] = useOnyx(ONYXKEYS.COLLECTION.POLICY, {selector: lastWorkspaceNumberSelectorWithEmail});
    return lastWorkspaceNumber;
}

and what the selector does (src/selectors/Policy.ts:330-348):

const workspaceRegex = isSMSDomain ? new RegExp(…) : new RegExp(`^(?=.*${escapedName})(?:.*(?:${WORKSPACE_TRANSLATIONS})\\s*(\\d+)?)`, 'i');
const workspaceNumbers = Object.values(policies ?? {})
    .map((policy) => workspaceRegex.exec(policy?.name ?? ''))
    …

So this constructs a RegExp and executes it against every policy name in the account. Two things make it worse here:

  1. lastWorkspaceNumberSelectorWithEmail is an inline arrow recreated on every render, so the selector identity is never stable. This same file already shows the right pattern eleven lines below:

    const filteredPoliciesInfoSelector = useMemo(() => createFilteredPoliciesInfoSelector(currentUserLogin), [currentUserLogin]);
    const [filteredPoliciesInfo] = useOnyx(ONYXKEYS.COLLECTION.POLICY, {selector: filteredPoliciesInfoSelector});
  2. This is now the third COLLECTION.POLICY subscription in one component, alongside useActivePolicy() and filteredPoliciesInfo.

The value is consumed in exactly one place, and only matters when the user has zero workspaces:

defaultWorkspaceName: generateDefaultWorkspaceName(currentUserLogin ?? '', lastWorkspaceNumber, translate, currentUserDisplayName),

ReportUtils.ts:12176 only reads defaultWorkspaceName inside if (filteredPoliciesCount === 0). So for every user who already has a workspace, this whole scan is computed on every header render and then thrown away.

ChatActionableButtons calls the hook the same way, but that component only mounts for a track-expense whisper. The expense header is a much hotter surface, and this is the area the INP work has been focused on.

Minimum fix, memoize the selector inside the hook so at least it only re-runs on POLICY changes rather than on every render:

function useLastWorkspaceNumber(email?: string) {
    const [sessionEmail] = useOnyx(ONYXKEYS.SESSION, {selector: emailSelector});
    const resolvedEmail = email ?? sessionEmail ?? '';
    const selector = useMemo(() => (policies: OnyxCollection<Policy>) => lastWorkspaceNumberSelector(policies, resolvedEmail), [resolvedEmail]);
    const [lastWorkspaceNumber] = useOnyx(ONYXKEYS.COLLECTION.POLICY, {selector});
    return lastWorkspaceNumber;
}

Better, skip the subscription entirely when it cannot be used, since you already have the count in this component:

const hasNoAccessibleWorkspace = (filteredPoliciesInfo?.filteredPoliciesCount ?? 0) === 0;
const lastWorkspaceNumber = useLastWorkspaceNumber(hasNoAccessibleWorkspace ? undefined : SKIP);

That needs a hook change, so the memoization fix alone is a reasonable scope for this PR with a follow-up for the rest.

Bug this causes: no functional bug, but on a high-traffic account with many workspaces every expense header render pays a regex pass over the full policy collection for a value most users never consume.

The PR checklist item "I tested this PR with a High Traffic account against the staging or production API" is unchecked, and this is exactly what it would surface.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 02c61cc with the memoization approach — the selector is now useMemo'd on resolvedEmail inside useLastWorkspaceNumber, so the POLICY-collection regex scan only re-runs when the email changes rather than on every render of every consumer (including the expense header). Kept the "skip the subscription entirely when the user already has a workspace" variant out of scope since it needs a hook API change — happy to do that as a follow-up.

Comment on lines +5002 to +5016
it('includes SEND_TO_EMPLOYER for an unreported self-tracked expense that is not a split', () => {
expect(getSendToSomeoneResult(false, false)).toContain(CONST.REPORT.TRANSACTION_SECONDARY_ACTIONS.SEND_TO_EMPLOYER);
});

it('hides SEND_TO_EMPLOYER for a self-DM split expense when the user has no workspace to submit to', () => {
expect(getSendToSomeoneResult(true, false)).not.toContain(CONST.REPORT.TRANSACTION_SECONDARY_ACTIONS.SEND_TO_EMPLOYER);
});

it('includes SEND_TO_EMPLOYER for a self-DM split expense when the user has a workspace to submit to', () => {
expect(getSendToSomeoneResult(true, true)).toContain(CONST.REPORT.TRANSACTION_SECONDARY_ACTIONS.SEND_TO_EMPLOYER);
});

it('hides SEND_TO_EMPLOYER on an archived self-DM (no write access)', () => {
expect(getSendToSomeoneResult(false, false, true)).not.toContain(CONST.REPORT.TRANSACTION_SECONDARY_ACTIONS.SEND_TO_EMPLOYER);
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 tests/unit/ReportSecondaryActionUtilsTest.ts:5002-5016: The new tests cannot fail on the thing most likely to be wrong

The four new tests are exact mirrors of the four existing SEND_TO_SOMEONE tests, asserted through the same helper. That is expected given both options are pushed under one condition, but it means the suite asserts the shared gate rather than checking the two options can diverge. If the fix in the first finding lands, these tests keep passing while the friend row silently keeps appearing for splits.

The test that would earn its place is the negative one:

it('hides SEND_TO_SOMEONE for a self-DM split expense even when the user has a workspace, since a split has no personal destination', () => {
    expect(getSendToSomeoneResult(true, true)).not.toContain(CONST.REPORT.TRANSACTION_SECONDARY_ACTIONS.SEND_TO_SOMEONE);
});

Also, getSendToSomeoneResult (line 4962) is now driving employer assertions too, and its inline comment still reads "mirror that so the write-action guard on SEND_TO_SOMEONE is exercised".

Worth renaming to getSelfDMConvertActionsResult or similar and updating the comment, otherwise the next person reading getSendToSomeoneResult(true, true)).toContain(SEND_TO_EMPLOYER) has to go read the helper to understand what is being set up.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 02c61cc. Renamed the helper to getSelfDMConvertActionsResult and updated its comment (it now drives both options, so the SEND_TO_SOMEONE-specific wording was misleading). Replaced the split+workspace "includes SEND_TO_SOMEONE" case with the negative assertion you suggested: hides SEND_TO_SOMEONE for a self-DM split expense even when the user has a workspace, since a split has no personal destination. That test now fails if the friend row ever starts sharing the employer gate again.

expect(getSendToSomeoneResult(false, false, true)).not.toContain(CONST.REPORT.TRANSACTION_SECONDARY_ACTIONS.SEND_TO_EMPLOYER);
});
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 No coverage for anything in MoneyRequestHeaderSecondaryActions.tsx or PopoverMenuSections.ts

All four new tests sit on the gating util. Untested:

  • that SEND_TO_EMPLOYER's onSelected passes submitDestination: EMPLOYER and a non-empty defaultWorkspaceName
  • that SEND_TO_SOMEONE's onSelected still passes neither, so it keeps defaulting to FRIEND
  • that both rows land in the top section of TRANSACTION_MORE_MENU_SECTIONS with the divider beneath
  • that the delegate guard fires on the new row

The params object is the part a future refactor is most likely to break, since it is now shared between two callers and spread with an override. A small assertion on the spread would be cheap:

it('passes the employer destination and a generated workspace name, and leaves Send to someone on the default friend destination', () => {
    // assert createDraftTransactionAndNavigateToParticipantSelector was called with
    // {submitDestination: 'employer', defaultWorkspaceName: expect.any(String)} for the employer row,
    // and with submitDestination undefined for the friend row
});

Bug this prevents: if someone later reorders the spread to {submitDestination: EMPLOYER, ...sendTrackedExpenseParams}, the override is silently dropped and Send to my employer quietly behaves as Send to someone. Nothing in the current suite catches that.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Partially addressed in 02c61cc, with a follow-up proposed for the rest.

Added a TRANSACTION_MORE_MENU_SECTIONS test in PopoverMenuSectionsTest.ts asserting both rows sort into the top section together, in order, with no divider between them and a divider before the next section.

For the onSelected params + delegate-guard assertions: there is no existing render harness for MoneyRequestHeaderSecondaryActions, and standing one up (mocking ~20 Onyx keys + navigation + opening the dropdown) is a disproportionate lift for this PR. I'd like to handle that as a follow-up so the params-spread regression you flagged gets a real render test. Does that work for you? If so I'll open a follow-up issue and link it here.

Comment thread src/languages/en.ts Outdated
enableWallet: 'Enable wallet',
hold: 'Hold',
sendToSomeone: 'Send to someone',
sendToEmployer: 'Send to my employer',

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Copy differs from the Inbox flow it mirrors, and the Design label is unchecked

en.ts:9741-9742 already has the Inbox strings:

actionableMentionTrackExpense: {
    submitToFriend: 'Submit to a friend',
    submitToEmployer: 'Submit to my employer',

This PR adds iou.sendToEmployer: 'Send to my employer'. The PR body itself calls the Inbox action "Submit to my employer", so the same action is Submit in one surface and Send in another.

Send is internally consistent with Send to someone in this menu, so this is probably deliberate since it matches sendToSomeone: 'Send to someone', but it is new user-facing copy and the checklist item "I added Design label and/or tagged @Expensify/design" is unchecked. Worth getting an explicit ack rather than assuming.

The ten translations themselves all read correctly and are placed in the right iou block in each file. No missing locale, no punctuation issues, no placeholder left behind ✅

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Changed to Submit to my employer in 02c61cc. Renamed the key iou.sendToEmployer -> iou.submitToEmployer and reused each locale's already-established actionableMentionTrackExpense.submitToEmployer copy, so the menu now matches the Inbox wording exactly rather than introducing new copy. (Shawn confirmed this direction over keeping "Send to my employer".)

@ikevin127

Copy link
Copy Markdown
Contributor

@shawnborton Dropped (5) code review comments that should be addressed before moving forward to manual testing - including this comment from Codex which is a real blocker 🔴

@shawnborton

Copy link
Copy Markdown
Contributor Author

Thanks @ikevin127 !

…loyer gate, memoize workspace-number selector, expand tests, use Submit copy
@OSBotify

Copy link
Copy Markdown
Contributor

🦜 Polyglot Parrot! 🦜

Squawk! Looks like you added some shiny new English strings. Allow me to parrot them back to you in other tongues:

View the translation diff
diff --git a/src/languages/de.ts b/src/languages/de.ts
index cc90feff..940cacff 100644
--- a/src/languages/de.ts
+++ b/src/languages/de.ts
@@ -1551,7 +1551,7 @@ const translations: TranslationDeepObject<typeof en> = {
         enableWallet: 'Wallet aktivieren',
         hold: 'Warteschleife',
         sendToSomeone: 'An jemanden senden',
-        submitToEmployer: 'An meinen Arbeitgeber senden',
+        submitToEmployer: 'Bei meinem Arbeitgeber einreichen',
         unhold: 'Zurückhalten aufheben',
         holdExpense: () => ({
             one: 'Ausgabe zurückhalten',
diff --git a/src/languages/es.ts b/src/languages/es.ts
index e9469f20..2d4584f4 100644
--- a/src/languages/es.ts
+++ b/src/languages/es.ts
@@ -1603,7 +1603,7 @@ const translations: TranslationDeepObject<typeof en> = {
         approveOnly: 'Solo aprobar',
         hold: 'Retener',
         sendToSomeone: 'Enviar a alguien',
-        submitToEmployer: 'Enviar a mi empleador',
+        submitToEmployer: 'Enviar a mi empresa',
         unhold: 'Desbloquear',
         holdEducationalTitle: '¿Deberías retener este gasto?',
         whatIsHoldExplain: 'Retener es como presionar "pausa" en un gasto hasta que estés listo para enviarlo.',
diff --git a/src/languages/ja.ts b/src/languages/ja.ts
index a939e2e7..8a2ca8c2 100644
--- a/src/languages/ja.ts
+++ b/src/languages/ja.ts
@@ -1531,7 +1531,7 @@ const translations: TranslationDeepObject<typeof en> = {
         enableWallet: 'ウォレットを有効にする',
         hold: '保留',
         sendToSomeone: '誰かに送る',
-        submitToEmployer: '勤務先に送信する',
+        submitToEmployer: '雇用主に提出する',
         unhold: '保留を解除',
         holdExpense: () => ({
             one: '経費を保留',
diff --git a/src/languages/pl.ts b/src/languages/pl.ts
index 05fbab11..ed190703 100644
--- a/src/languages/pl.ts
+++ b/src/languages/pl.ts
@@ -1541,7 +1541,7 @@ const translations: TranslationDeepObject<typeof en> = {
         enableWallet: 'Włącz portfel',
         hold: 'Wstrzymaj',
         sendToSomeone: 'Wyślij do kogoś',
-        submitToEmployer: 'Prześlij do mojego pracodawcy',
+        submitToEmployer: 'Wyślij do mojego pracodawcy',
         unhold: 'Usuń blokadę',
         holdExpense: () => ({
             one: 'Wstrzymaj wydatek',

Note

You can apply these changes to your branch by copying the patch to your clipboard, then running pbpaste | git apply 😉

View workflow run

@ikevin127

ikevin127 commented Aug 24, 2026 •

Copy link
Copy Markdown
Contributor

Reviewer Checklist

  • I have verified the author checklist is complete (all boxes are checked off).
  • I verified the correct issue is linked in the ### Fixed Issues section above
  • I verified testing steps are clear and they cover the changes made in this PR
    • I verified the steps for local testing are in the Tests section
    • I verified the steps for Staging and/or Production testing are in the QA steps section
    • I verified the steps cover any possible failure scenarios (i.e. verify an input displays the correct error message if the entered data is not correct)
    • I turned off my network connection and tested it while offline to ensure it matches the expected behavior (i.e. verify the default avatar icon is displayed if app is offline)
  • I checked that screenshots or videos are included for tests on all platforms
  • I included screenshots or videos for tests on all platforms
  • I verified that the composer does not automatically focus or open the keyboard on mobile unless explicitly intended. This includes checking that returning the app from the background does not unexpectedly open the keyboard.
  • I verified tests pass on all platforms & I tested again on:
    • Android: HybridApp
    • Android: mWeb Chrome
    • iOS: HybridApp
    • iOS: mWeb Safari
    • MacOS: Chrome / Safari
  • If there are any errors in the console that are unrelated to this PR, I either fixed them (preferred) or linked to where I reported them in Slack
  • I verified proper code patterns were followed (see Reviewing the code)
    • I verified that comments were added to code that is not self explanatory
    • I verified that any new or modified comments were clear, correct English, and explained "why" the code was doing something instead of only explaining "what" the code was doing.
    • I verified any copy / text that was added to the app is grammatically correct in English. It adheres to proper capitalization guidelines (note: only the first word of header/labels should be capitalized), and is either coming verbatim from figma or has been approved by marketing (in order to get marketing approval, ask the Bug Zero team member to add the Waiting for copy label to the issue)
  • If a new code pattern is added I verified it was agreed to be used by multiple Expensify engineers
  • I verified that this PR follows the guidelines as stated in the Review Guidelines
  • I verified other components that can be impacted by these changes have been tested, and I retested again (i.e. if the PR modifies a shared library or component like Avatar, I verified the components using Avatar have been tested & I retested again)
  • If a new component is created I verified that:
    • A similar component doesn't exist in the codebase
    • All props are defined accurately
    • The component has a clear name that is non-ambiguous and the purpose of the component can be inferred from the name alone
    • The only data being stored in the state is data necessary for rendering and nothing else
    • The component has the minimum amount of code necessary for its purpose, and it is broken down into smaller components in order to separate concerns and functions
  • If a new CSS style is added I verified that:
    • A similar style doesn't already exist
    • The style can't be created with an existing StyleUtils function (i.e. StyleUtils.getBackgroundAndBorderStyle(theme.componentBG)
  • If the PR modifies code that runs when editing or sending messages, I tested and verified there is no unexpected behavior for all supported markdown - URLs, single line code, code blocks, quotes, headings, bold, strikethrough, and italic.
  • If the PR modifies a generic component, I tested and verified that those changes do not break usages of that component in the rest of the App (i.e. if a shared library or component like Avatar is modified, I verified that Avatar is working as expected in all cases)
  • If the PR modifies a component related to any of the existing Storybook stories, I tested and verified all stories for that component are still working as expected.
  • If the PR modifies a component or page that can be accessed by a direct deeplink, I verified that the code functions as expected when the deeplink is used - from a logged in and logged out account.
  • If the PR modifies the UI (e.g. new buttons, new UI components, changing the padding/spacing/sizing, moving components, etc) or modifies the form input styles:
    • I verified that all the inputs inside a form are aligned with each other.
    • I added Design label and/or tagged @Expensify/design so the design team can review the changes.
  • For any bug fix or new feature in this PR, I verified that sufficient unit tests are included to prevent regressions in this flow.
  • If the main branch was merged into this PR after a review, I tested again and verified the outcome was still expected according to the Test steps.
  • I have checked off every checkbox in the PR reviewer checklist, including those that don't apply to this PR.

Screenshots/Videos

Screen.Recording.2026-08-24.at.15.38.19.mov

@ikevin127

Copy link
Copy Markdown
Contributor

Re-review of #99247 at 02c61ccf

@shawnborton All six items from the previous round are addressed. One thing in the PR body that will bite QA 👇

⚠️ The PR body needs updating before QA sees it

This is the one thing I would not merge without. The ### Tests and ### Offline tests steps still say Send to my employer in four places, but the shipped copy is now Submit to my employer. QA follows those steps literally, so step 4 ("Verify a Send to my employer option appears") will be reported as a failure against a working build.

Also outstanding, and what the failing checklist CI job is flagging:

  • all PR Author Checklist checkboxes are unchecked at the moment which is failing PR Author Checklist CI

@shawnborton

Copy link
Copy Markdown
Contributor Author

@MelvinBot please implement the feedback here

@MelvinBot

Copy link
Copy Markdown
Contributor

Done — updated the PR body so the test steps match the shipped copy.

The ### Tests and ### Offline tests steps (and the two mentions in ### Explanation of Change, for consistency) now say Submit to my employer instead of Send to my employer, matching the actual UI label from translate('iou.submitToEmployer') → 'Submit to my employer'. That's all 6 occurrences in the body corrected, so QA won't hit a false failure on step 4.

The other item — the PR Author Checklist boxes / failing checklist CI — is already resolved: every Author Checklist box is checked and the latest checklist run passes. (The only two unchecked boxes left are the human "Verify no errors appear in the JS console" items in the Tests/QA sections, which are yours to confirm.)

Note: the PR title still reads "Add Send to my employer option…" — I left it as-is since it wasn't flagged, but let me know if you'd like it renamed to match too.


view run

@ikevin127 ikevin127 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 LGTM - PR tests well, all review-comments were addressed

@melvin-bot
melvin-bot Bot requested a review from francoisl August 25, 2026 00:32
@melvin-bot

melvin-bot Bot commented Aug 25, 2026

Copy link
Copy Markdown

@francoisl Please copy/paste the Reviewer Checklist from here into a new comment on this PR and complete it. If you have the K2 extension, you can simply click: [this button]

@francoisl
francoisl merged commit 1d0d7de into main Aug 25, 2026
46 of 67 checks passed
@francoisl
francoisl deleted the shawn-lookingAround branch August 25, 2026 02:14
@github-actions

Copy link
Copy Markdown
Contributor

🚧 francoisl has triggered a test Expensify/App build. You can view the workflow run here.

@OSBotify

Copy link
Copy Markdown
Contributor

✋ This PR was not deployed to staging yet because QA is ongoing. It will be automatically deployed to staging after the next production release.

@OSBotify

Copy link
Copy Markdown
Contributor

🚀 Deployed to staging by https://github.com/francoisl in version: 9.4.61-0 🚀

platform result
🕸 web 🕸 success ✅
🤖 android 🤖 success ✅
🍎 iOS 🍎 success ✅

@MelvinBot

Copy link
Copy Markdown
Contributor

Help site review — changes required ✅

This PR adds a Submit to my employer option (alongside Send to someone) to a tracked/personal expense's More menu — a new entry point to a user-facing flow. The help site's Track Expenses article enumerates what you can do with a tracked expense but didn't mention this menu, so it's now out of date.

Draft help site PR: #99480 — updates the What you can do with tracked expenses section of Track-Expenses.md to document the Send to someone and Submit to my employer options on a tracked expense's More menu.

Label verification

Exact labels taken from the source of truth in src/languages/en.ts that this PR renders via MoneyRequestHeaderSecondaryActions.tsx:

  • iou.sendToSomeone → Send to someone
  • iou.submitToEmployer → Submit to my employer

Note: the Inbox Concierge whisper on a tracked expense uses a separate, pre-existing set of labels (actionableMentionTrackExpense: Submit to a friend / Submit to my employer) — that's why the UI check below saw "Submit to a friend" rather than "Send to someone" (it landed on the Concierge whisper variant, not the new More dropdown).

UI verification (web)

Overall: partial — a self-tracked personal expense was created and its actions menu inspected on dev NewDot web. Submit to my employer appears verbatim.

Step Status Observation
"Submit to my employer" option appears in the More menu ✅ pass Menu lists a row with the exact label Submit to my employer
"Send to someone" option appears in the More menu ⚠️ fail The affordance opened landed on the Concierge whisper variant, which labels the sibling Submit to a friend; the new More dropdown label is Send to someone per source
Exact wording of all options ✅ pass Observed: Pin, Share, Settings, Submit to a friend, Submit to my employer, Delete expense
Evidence (2)

Tracked expense More menu showing Submit to my employer option

Tracked expense More menu showing Submit to my employer option

Tracked expense More menu default view

Tracked expense More menu default view

@shawnborton, please review the linked help site PR and confirm it reflects the current behavior. Then mark the linked help site PR Ready for review


view run

@OSBotify

Copy link
Copy Markdown
Contributor

🚀 Deployed to production by https://github.com/AndrewGable in version: 9.4.61-4 🚀

platform result
🕸 web 🕸 success ✅
🤖 android 🤖 success ✅
🍎 iOS 🍎 success ✅

Bundle Size Analysis (Sentry):

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants